From 4c9fe46fb0d919d1b2b848e51392343c48cf4a52 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:35:38 -0400 Subject: [PATCH 1/5] Migrate the dashboard to spaday and serve it outside the cluster The dashboard was a Ray Serve FastAPI ingress holding the perspective tables, paired with a hand-rolled esbuild/pnpm frontend. Because the browser had to reach that ingress, it required an inbound port on the cluster, which is not always possible. Replace the frontend with spaday, spaday-perspective and spaday-webawesome, and move the table host out of the cluster by default. The tracker actor now buffers table operations that the dashboard pulls over Ray's existing connection, so the browser only talks to localhost and the cluster accepts no new traffic. UI state syncs over transports; perspective keeps its own websocket for bulk data. Two dashboard modes are available: RayTaskTracker(dashboard="local") served from the caller's process RayTaskTracker(dashboard="cluster") served from Ray Serve, as before Pin spaday's asset layout to "installed". Its source/installed detection keys off a js/ directory beside the package, and unrelated wheels create one in site-packages, which 404s the runtime. Build the Ray Serve app in __serve_build_asgi_app__ rather than passing it to ingress. Ray pickles an ingress app to ship it to the replica, which fails on the perspective server and the transports store. Drop js/, the hatch-js build hook and the NodeJS toolchain; raydar is now pure Python. Requires Python 3.11 for spaday-perspective, and moves perspective-python from 3.4 to 4.5. Remove PerspectiveRayServer, PerspectiveProxyRayServer and setup_proxy_server. Table creation and updates go through RayTaskTracker.create_table and update_table as before. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- .github/dependabot.yaml | 9 - .github/workflows/build.yaml | 6 +- .gitignore | 11 - .vscode/settings.json | 4 +- Makefile | 60 +- README.md | 26 +- docs/wiki/Installation.md | 2 +- docs/wiki/Key-Features.md | 74 +- docs/wiki/contribute/Build-from-Source.md | 41 +- js/build.mjs | 62 - js/package.json | 58 - js/pnpm-lock.yaml | 3141 --------------------- js/pnpm-workspace.yaml | 2 - js/src/index.css | 44 - js/src/index.html | 22 - js/src/index.js | 107 - js/src/layouts/default.json | 1 - js/tools/build.js | 38 - js/tools/css.js | 38 - js/tools/getarg.js | 23 - pyproject.toml | 53 +- raydar/dashboard/__init__.py | 6 +- raydar/dashboard/dashboard.py | 148 + raydar/dashboard/demo.py | 72 +- raydar/dashboard/local.py | 96 + raydar/dashboard/page.py | 64 + raydar/dashboard/serve.py | 32 + raydar/dashboard/server.py | 131 - raydar/dashboard/state.py | 32 + raydar/ops.py | 63 + raydar/task_tracker/task_tracker.py | 208 +- raydar/tests/test_dashboard.py | 96 + raydar/tests/test_ops.py | 63 + raydar/tests/test_serve.py | 31 + raydar/tests/test_task_tracker.py | 70 +- 35 files changed, 916 insertions(+), 4018 deletions(-) delete mode 100644 js/build.mjs delete mode 100644 js/package.json delete mode 100644 js/pnpm-lock.yaml delete mode 100644 js/pnpm-workspace.yaml delete mode 100644 js/src/index.css delete mode 100644 js/src/index.html delete mode 100644 js/src/index.js delete mode 100644 js/src/layouts/default.json delete mode 100644 js/tools/build.js delete mode 100644 js/tools/css.js delete mode 100644 js/tools/getarg.js create mode 100644 raydar/dashboard/dashboard.py create mode 100644 raydar/dashboard/local.py create mode 100644 raydar/dashboard/page.py create mode 100644 raydar/dashboard/serve.py delete mode 100644 raydar/dashboard/server.py create mode 100644 raydar/dashboard/state.py create mode 100644 raydar/ops.py create mode 100644 raydar/tests/test_dashboard.py create mode 100644 raydar/tests/test_ops.py create mode 100644 raydar/tests/test_serve.py diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 92a5e0d..42cac77 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -14,12 +14,3 @@ updates: labels: - "lang: python" - "part: dependencies" - - - package-ecosystem: "npm" - directory: "/js" - schedule: - interval: "monthly" - labels: - - "lang: javascript" - - "part: dependencies" - \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 888f82e..9e05636 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -39,10 +39,6 @@ jobs: with: version: ${{ matrix.python-version }} - - uses: actions-ext/node/setup@main - with: - version: 22.x - - name: Install dependencies run: make develop @@ -64,7 +60,7 @@ jobs: - name: Upload test results uses: actions/upload-artifact@v7 with: - name: test-results-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.node-version }} + name: test-results-${{ matrix.os }}-${{ matrix.python-version }} path: '**/junit.xml' if: ${{ always() }} diff --git a/.gitignore b/.gitignore index e79b750..f72fe76 100644 --- a/.gitignore +++ b/.gitignore @@ -120,17 +120,6 @@ docs/src/_build/ docs/superpowers index.md -# JS -js/coverage -js/dist -js/lib -js/node_modules -js/test-results -js/playwright-report -js/*.tgz -raydar/dashboard/static/* -!raydar/dashboard/static/index.psp2.js - # Jupyter .ipynb_checkpoints .autoversion diff --git a/.vscode/settings.json b/.vscode/settings.json index dab99f9..0967ef4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1 @@ -{ - "eslint.workingDirectories": ["./js"] -} \ No newline at end of file +{} diff --git a/Makefile b/Makefile index 65c59b9..59b518a 100644 --- a/Makefile +++ b/Makefile @@ -1,34 +1,20 @@ ######### # BUILD # ######### -.PHONY: develop-py develop-js develop -develop-py: +.PHONY: develop +develop: ## setup project for development uv pip install -e .[develop] -develop-js: requirements-js - -develop: develop-js develop-py ## setup project for development - -.PHONY: requirements-py requirements-js requirements -requirements-py: ## install prerequisite python build requirements +.PHONY: requirements +requirements: ## install prerequisite python build requirements python -m pip install --upgrade pip toml python -m pip install `python -c 'import toml; c = toml.load("pyproject.toml"); print("\n".join(c["build-system"]["requires"]))'` python -m pip install `python -c 'import toml; c = toml.load("pyproject.toml"); print(" ".join(c["project"]["optional-dependencies"]["develop"]))'` -requirements-js: ## install prerequisite javascript build requirements - cd js; pnpm install && npx playwright install - -requirements: requirements-js requirements-py ## setup project for development - -.PHONY: build-py build-js build -build-py: +.PHONY: build +build: ## build the project python -m build -w -n -build-js: - cd js; pnpm build - -build: build-js build-py ## build the project - .PHONY: install install: ## install python library uv pip install . @@ -36,36 +22,30 @@ install: ## install python library ######### # LINTS # ######### -.PHONY: lint-py lint-js lint lints +.PHONY: lint-py lint-docs lint lints lint-py: ## run python linter with ruff python -m ruff check raydar python -m ruff format --check raydar -lint-js: ## run js linter - cd js; pnpm lint - lint-docs: ## lint docs with mdformat and codespell python -m mdformat --check README.md docs/wiki/ python -m codespell_lib README.md docs/wiki/ -lint: lint-js lint-py lint-docs ## run project linters +lint: lint-py lint-docs ## run project linters # alias lints: lint -.PHONY: fix-py fix-js fix-docs fix format +.PHONY: fix-py fix-docs fix format fix-py: ## fix python formatting with ruff python -m ruff check --fix raydar python -m ruff format raydar -fix-js: ## fix js formatting - cd js; pnpm fix - fix-docs: ## autoformat docs with mdformat and codespell python -m mdformat README.md docs/wiki/ python -m codespell_lib --write README.md docs/wiki/ -fix: fix-js fix-py fix-docs ## run project autoformatters +fix: fix-py fix-docs ## run project autoformatters # alias format: fix @@ -99,18 +79,9 @@ tests-py: test-py coverage-py: ## run python tests and collect test coverage python -m pytest -v raydar/tests --cov=raydar --cov-report term-missing --cov-report xml -.PHONY: test-js tests-js coverage-js -test-js: ## run js tests - cd js; pnpm test - -# alias -tests-js: test-js - -coverage-js: test-js ## run js tests and collect test coverage - .PHONY: test coverage tests -test: test-py test-js ## run all tests -coverage: coverage-py coverage-js ## run all tests and collect test coverage +test: test-py ## run all tests +coverage: coverage-py ## run all tests and collect test coverage # alias tests: test @@ -135,18 +106,15 @@ major: ## bump a major version ######## # DIST # ######## -.PHONY: dist dist-py dist-js dist-check publish +.PHONY: dist dist-py dist-check publish dist-py: ## build python dists python -m build -w -s -dist-js: # build js dists - cd js; pnpm pack - dist-check: ## run python dist checker with twine python -m twine check dist/* -dist: clean build dist-js dist-py dist-check ## build all dists +dist: clean build dist-py dist-check ## build all dists publish: dist ## publish python assets diff --git a/README.md b/README.md index a8a19eb..c17adfb 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,21 @@ [![License](https://img.shields.io/github/license/Point72/raydar)](https://github.com/Point72/raydar) [![PyPI](https://img.shields.io/pypi/v/raydar.svg)](https://pypi.python.org/pypi/raydar) -A [perspective](https://perspective.finos.org/) powered, user editable ray dashboard via ray serve. +A [perspective](https://perspective.finos.org/) powered, user editable ray dashboard. Ray offers powerful metrics visualizations powered by graphana and prometheus. Although useful, the setup can take time - and customizations can be challenging. Raydar, enables out-of-the-box live cluster metrics and user visualizations for Ray workflows with just a simple pip install. It helps unlock distributed machine learning visualizations on Anyscale clusters, runs live and at scale, is easily customizable, and enables all the in-browser aggregations that [perspective](https://perspective.finos.org/) has to offer. +By default the dashboard runs in **your** process and pulls data over the Ray connection you already have, so the cluster never needs an inbound port. + ![Example](https://media.githubusercontent.com/media/Point72/raydar/refs/heads/main/docs/img/ml_example.gif) ## Features - Convenience wrappers for the tracking and persistence of ray GCS task metadata. Can scale beyond the existing ray dashboard / GCS task tracking limitations. -- Serves a UI through [ray serve](https://docs.ray.io/en/latest/serve/index.html) for the visualization of [perspective](https://github.com/finos/perspective) tables. +- A UI built with [spaday](https://github.com/1kbgz/spaday), [spaday-perspective](https://github.com/1kbgz/spaday-perspective) and [spaday-webawesome](https://github.com/1kbgz/spaday-webawesome) — authored in Python, with UI state synced over [transports](https://github.com/1kbgz/transports). +- Serve it locally (no open port on the cluster) or from [ray serve](https://docs.ray.io/en/latest/serve/index.html). - A python interface to create and update perspective tables from within ray tasks. [More information is available in our wiki](https://github.com/Point72/raydar/wiki) @@ -36,7 +39,8 @@ The raydar module provides an actor which can process collections of ray object ```python from raydar import RayTaskTracker -task_tracker = RayTaskTracker(enable_perspective_dashboard=True) +task_tracker = RayTaskTracker(dashboard="local") +print(task_tracker.dashboard_url) ``` Passing collections of object references to this actor's process method causes those references to be tracked in an internal polars dataframe, as they finish running. @@ -55,7 +59,9 @@ refs = [example_remote_function.remote() for _ in range(100)] task_tracker.process(refs) ``` -The perspective UI is served on port 8000 by default. +The UI is served from this process on a free local port, printed by `task_tracker.dashboard_url`. Pass `dashboard_port=` to pin it. Data reaches the dashboard over Ray's existing connection, so nothing needs to listen on the cluster. + +If your cluster already exposes Ray Serve's HTTP ingress, `dashboard="cluster"` serves the same UI from a Ray Serve deployment instead. ![Example](https://media.githubusercontent.com/media/Point72/raydar/refs/heads/main/docs/img/example_perspective_dashboard.gif) @@ -65,7 +71,7 @@ Passing a `name` and `namespace` arguments allows the RayTaskTracker to skip con from raydar import RayTaskTracker task_tracker = RayTaskTracker( - enable_perspective_dashboard=True, + dashboard="local", name="my_actor_name", namespace="my_actor_namespace" ) @@ -109,13 +115,15 @@ for i in range(100): - _Where is the perspective data stored?_ -Currently, in memory. There are plans to integrate alternatives to this configuration, but currently the data is stored in machine memory on the ray head. +Currently, in memory. With `dashboard="local"` that is the memory of the process that created the `RayTaskTracker`; with `dashboard="cluster"` it is the Ray Serve replica on the ray head. -- _How can I save and restore my perspective layouts?_ +- _Does the cluster need an open port?_ -The `Save Layout` button saves a json file containing layout information. Dragging and dropping this file into the UI browser window restores that layout. +Not with `dashboard="local"`, the default topology. The dashboard binds a port on your own machine and pulls table updates from the tracker actor over Ray, so the browser only ever talks to localhost. `dashboard="cluster"` does need Ray Serve's HTTP ingress to be reachable. + +- _How can I save and restore my perspective layouts?_ -![Example](https://media.githubusercontent.com/media/Point72/raydar/refs/heads/main/docs/img/layout_restoration.gif) +Layouts are Python-side. Pass a perspective-workspace layout to the dashboard and it is restored in every connected tab. ## License diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index d11ae42..99108a8 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -1,6 +1,6 @@ ## Pre-requisites -You need Python >=3.10 on your machine to install `raydar`. +You need Python >=3.11 on your machine to install `raydar`. ## Install with `pip` diff --git a/docs/wiki/Key-Features.md b/docs/wiki/Key-Features.md index 1e5b605..62b2aeb 100644 --- a/docs/wiki/Key-Features.md +++ b/docs/wiki/Key-Features.md @@ -37,42 +37,41 @@ This internal dataframe can be accessed via the `.get_df()` method. | e0dc174c83... | `null` | 0 | `example_remote_function` | ... | 2024-01-29 07:17:09.343 EST | 2024-01-29 07:17:12.115 EST | `{"/tmp/ray/session_2024-01-29_07...` | `null` | | f4402ec78d... | `null` | 0 | `example_remote_function` | ... | 2024-01-29 07:17:09.343 EST | 2024-01-29 07:17:12.115 EST | `{"/tmp/ray/session_2024-01-29_07...` | `null` | -Additionally, setting the `enable_perspective_dashboard` flag to `True` in the `RayTaskTracker`'s construction serves a perspective dashboard with live views of your completed references. +Additionally, passing `dashboard="local"` to the `RayTaskTracker`'s construction serves a perspective dashboard with live views of your completed references. ```python -task_tracker = RayTaskTracker(enable_perspective_dashboard=True) +task_tracker = RayTaskTracker(dashboard="local") +print(task_tracker.dashboard_url) ``` +The dashboard runs in this process and pulls updates from the tracker actor over Ray, so the cluster needs no inbound port. Use `dashboard="cluster"` to serve it from Ray Serve instead, when the cluster's HTTP ingress is reachable. + ![Example](images/example_perspective_dashboard.gif) ## Create/Store Custom Views -From the developer console, save your workspace layout locally. - -```javascript -let workspace = document.getElementById("perspective-workspace"); - -// Save the current layout -workspace.save().then((config) => { - // Convert the configuration object to a JSON string - let json = JSON.stringify(config); - - // Create a Blob object from the JSON string - let blob = new Blob([json], { type: "application/json" }); +Layouts live in Python. Pass a [perspective-workspace](https://perspective.finos.org/) layout and it is restored in every connected tab: - // Create a download link - let link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "workspace.json"; +```python +layout = { + "sizes": [1], + "detail": {"main": {"type": "tab-area", "widgets": ["task_tracker_data"], "currentIndex": 0}}, + "master": {"sizes": [], "widgets": []}, + "mode": "globalFilters", + "viewers": { + "task_tracker_data": { + "table": "task_tracker_data", + "plugin": "Datagrid", + "group_by": ["func_or_class_name"], + "columns": ["state"], + } + }, +} - // Append the link to the document body and click it to start the download - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -}); +task_tracker = RayTaskTracker(dashboard="local", dashboard_options={"layout": layout}) ``` -Then, move this json file to `js/src/layouts/default.json`. +`dashboard_options` also accepts `title` and `limit` (a per-table row cap). Without a layout override, raydar generates one datagrid tab per table. ![Example](images/example_perspective_dashboard_layouts.gif) @@ -111,13 +110,11 @@ Specifically, tracked fields include: ## Custom Sources / Update Logic -The proxy server helpd by the `RayTaskTracker` is exposed via the `.proxy_server()` property, meaning we can create new tables as follows: +The `RayTaskTracker` can create and update arbitrary tables: ```python -task_tracker = RayTaskTracker(enable_perspective_dashboard=True) -proxy_server = task_tracker.proxy_server() -proxy_server.remote( - "new", +task_tracker = RayTaskTracker(dashboard="local") +task_tracker.create_table( "metrics_table", { "node_id": "string", @@ -133,18 +130,17 @@ proxy_server.remote( If a user were to then update this table with data coming from, for example, a pytorch model training loop with metrics: ```python -def my_model_training_loop() - - for epoch in range(num_epochs): +def my_model_training_loop(): + for epoch in range(num_epochs): # ... my training code here ... - data = dict( - node_id=ray.get_runtime_context().get_node_id(), - metric_name="loss", - value=loss.item(), - timestamp=time.time(), - ) - proxy_server.remote("update", "metrics_table", [data]) + data = dict( + node_id=ray.get_runtime_context().get_node_id(), + metric_name="loss", + value=loss.item(), + timestamp=time.time(), + ) + task_tracker.update_table("metrics_table", [data]) ``` Then they can expose a live view at per-node loss metrics across our model training process: diff --git a/docs/wiki/contribute/Build-from-Source.md b/docs/wiki/contribute/Build-from-Source.md index 1986de3..a70b339 100644 --- a/docs/wiki/contribute/Build-from-Source.md +++ b/docs/wiki/contribute/Build-from-Source.md @@ -1,4 +1,4 @@ -`raydar` is written in Python and JavaScript. While prebuilt wheels are provided for end users, it is also straightforward to build `raydar` from either the Python [source distribution](https://packaging.python.org/en/latest/specifications/source-distribution-format/) or the GitHub repository. +`raydar` is written in Python. While prebuilt wheels are provided for end users, it is also straightforward to build `raydar` from either the Python [source distribution](https://packaging.python.org/en/latest/specifications/source-distribution-format/) or the GitHub repository. - [Make commands](#make-commands) - [Prerequisites](#prerequisites) @@ -36,14 +36,6 @@ git clone https://github.com/Point72/raydar.git cd raydar ``` -## Install NodeJS - -Follow the instructions for [installing NodeJS](https://nodejs.org/en/download/package-manager/all) for your system. Once installed, you can [install `pnpm`](https://pnpm.io/installation) with: - -```bash -npm install --global pnpm -``` - ## Install Python dependencies Python build and develop dependencies are specified in the `pyproject.toml`, but you can manually install them: @@ -66,13 +58,12 @@ make build `raydar` has linting and auto formatting. -| Language | Linter | Autoformatter | Description | -| :--------- | :---------- | :------------ | :---------- | -| Python | `ruff` | `ruff` | Style | -| Python | `ruff` | `ruff` | Imports | -| JavaScript | `prettier` | `prettier` | Style | -| Markdown | `mdformat` | `mdformat` | Style | -| Markdown | `codespell` | | Spelling | +| Language | Linter | Autoformatter | Description | +| :------- | :---------- | :------------ | :---------- | +| Python | `ruff` | `ruff` | Style | +| Python | `ruff` | `ruff` | Imports | +| Markdown | `mdformat` | `mdformat` | Style | +| Markdown | `codespell` | | Spelling | **Python Linting** @@ -86,18 +77,6 @@ make lint-py make fix-py ``` -**JavaScript Linting** - -```bash -make lint-js -``` - -**JavaScript Autoformatting** - -```bash -make fix-js -``` - **Documentation Linting** ```bash @@ -123,9 +102,3 @@ make develop ```bash make test-py ``` - -**JavaScript** - -```bash -make test-js -``` diff --git a/js/build.mjs b/js/build.mjs deleted file mode 100644 index 8c687b9..0000000 --- a/js/build.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import { build } from "./tools/build.js"; -import { compile } from "./tools/css.js"; -import fs from "fs"; -import cpy from "cpy"; - -const BUILD = [ - { - define: { - global: "window", - }, - entryPoints: ["src/index.js"], - bundle: true, - plugins: [], - format: "esm", - loader: { - ".css": "text", - ".html": "text", - ".jsx": "jsx", - ".png": "file", - ".ttf": "file", - ".wasm": "file", - }, - outfile: "./dist/index.js", - publicPath: "/static/", - }, -]; - -async function build_all() { - /* make directories */ - fs.mkdirSync("../raydar/dashboard/static/", { recursive: true }); - - /* Compile JS */ - await Promise.all(BUILD.map(build)).catch(() => process.exit(1)); - // await cp_to_paths("./src/style/*.css"); - await cpy("./src/*.html", "./dist", { flat: true }); - await cpy("./src/layouts/*", "./dist/layouts", { flat: true }); - await cpy( - "./node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm", - "./dist", - { flat: true }, - ); - await cpy( - "./node_modules/@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm", - "./dist", - { flat: true }, - ); - - /* Compile css */ - await compile(); - - /* Copy to raydar static */ - await cpy("./dist/*", "../raydar/dashboard/static/", { - flat: true, - recursive: true, - }); - await cpy("./dist/layouts/*", "../raydar/dashboard/static/layouts/", { - flat: true, - recursive: true, - }); -} - -build_all(); diff --git a/js/package.json b/js/package.json deleted file mode 100644 index 56f8c8e..0000000 --- a/js/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "raydar", - "version": "0.3.0", - "description": "A perspective powered, user editable ray dashboard via ray serve", - "repository": "git@github.com:Point72/raydar.git", - "author": "Point72, L.P. ", - "license": "Apache-2.0", - "private": true, - "type": "module", - "unpkg": "dist/cdn/index.js", - "jsdelivr": "dist/cdn/index.js", - "exports": { - ".": { - "types": "./dist/esm/index.d.ts", - "default": "./dist/esm/index.js" - }, - "./dist/*": "./dist/*", - "./package.json": "./package.json" - }, - "files": [ - "dist/**/*", - "index.d.ts" - ], - "types": "./dist/esm/index.d.ts", - "publishConfig": { - "access": "public" - }, - "scripts": { - "build:debug": "node build.mjs --debug", - "build:esbuild": "node build.mjs", - "build": "npm-run-all -s build:*", - "clean": "rm -rf dist playwright-report ../raydar/dashboard/static", - "dev": "npm-run-all -p start watch", - "lint": "prettier --check \"src/**/*.{js,ts,jsx,tsx,css}\" \"*.mjs\" \"*.json\"", - "fix": "prettier --write \"src/**/*.{js,ts,jsx,tsx,css}\" \"*.mjs\" \"*.json\"", - "preinstall": "npx only-allow pnpm", - "prepack": "npm run build", - "test": ":", - "watch:esbuild": "pnpm run build:esbuild --watch", - "watch": "npm-run-all -p watch:*" - }, - "dependencies": { - "@perspective-dev/client": "^4.3.0", - "@perspective-dev/server": "^4.3.0", - "@perspective-dev/viewer": "^4.3.0", - "@perspective-dev/viewer-d3fc": "^4.3.0", - "@perspective-dev/viewer-datagrid": "^4.3.0", - "@perspective-dev/workspace": "^4.3.0" - }, - "devDependencies": { - "cpy": "^13.2.1", - "esbuild": "^0.28.1", - "lightningcss": "^1.33.0", - "mkdirp": "^3.0.1", - "npm-run-all": "^4.1.5", - "prettier": "^3.8.3" - } -} diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml deleted file mode 100644 index 18b0cef..0000000 --- a/js/pnpm-lock.yaml +++ /dev/null @@ -1,3141 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@perspective-dev/client': - specifier: ^4.3.0 - version: 4.3.0 - '@perspective-dev/server': - specifier: ^4.3.0 - version: 4.3.0 - '@perspective-dev/viewer': - specifier: ^4.3.0 - version: 4.3.0 - '@perspective-dev/viewer-d3fc': - specifier: ^4.3.0 - version: 4.3.0(d3-brush@3.0.0)(d3-dispatch@3.0.1)(d3-fetch@3.0.1)(d3-path@3.1.0)(d3-random@3.0.1)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-shape@3.2.0)(d3-time@3.1.0)(d3-zoom@3.0.0) - '@perspective-dev/viewer-datagrid': - specifier: ^4.3.0 - version: 4.3.0 - '@perspective-dev/workspace': - specifier: ^4.3.0 - version: 4.3.0 - devDependencies: - cpy: - specifier: ^13.2.1 - version: 13.2.1 - esbuild: - specifier: ^0.28.1 - version: 0.28.1 - lightningcss: - specifier: ^1.33.0 - version: 1.33.0 - mkdirp: - specifier: ^3.0.1 - version: 3.0.1 - npm-run-all: - specifier: ^4.1.5 - version: 4.1.5 - prettier: - specifier: ^3.8.3 - version: 3.8.3 - -packages: - - '@d3fc/d3fc-annotation@3.0.16': - resolution: {integrity: sha512-4tA7RHLUbj/i3KqWRqCVDOHi435qBRXbAFrAajVYM2juNzFdX5RCoF3opyXKOFncs339caxgeySGStTVfulO9w==} - peerDependencies: - d3-scale: '*' - d3-selection: '*' - - '@d3fc/d3fc-axis@3.0.7': - resolution: {integrity: sha512-S4pILxkQUkD7WQmimWxIEHfXUYEonlXWuWvMP6iq3KXL3d+cj4flvwNqLYZvSVaQDdK990cKkjrK/ZAKnReGlg==} - peerDependencies: - d3-scale: '*' - d3-selection: '*' - d3-shape: '*' - - '@d3fc/d3fc-brush@3.0.3': - resolution: {integrity: sha512-fc1XBuNWl6DQVFSnBdauI/WNRvtNaeUWwDiLEIMY+v0VlrmGOgWEGCB5otVepGZiL56cjgRtHdwYBdKCvgGBSg==} - peerDependencies: - d3-brush: '*' - d3-dispatch: '*' - d3-scale: '*' - d3-selection: '*' - - '@d3fc/d3fc-chart@5.1.9': - resolution: {integrity: sha512-xFO9lDUi2wAkjfyWlVZuFwSWEgnk0WOEagR2R9KzQ+uxeg3oZOPzwE+AZbpALyQbjjQ2uIxcOk56xnmnnayk3w==} - peerDependencies: - d3-scale: '*' - d3-selection: '*' - - '@d3fc/d3fc-data-join@6.0.3': - resolution: {integrity: sha512-fd1D2Cl4YGjzl3gBhcrvTl/VxaSncY0ZcokWsN8ahtmk9DZK4DnAgHGrdecnXVLkOx+ANDcqxqscYz6MWXLbcA==} - peerDependencies: - d3-selection: '*' - - '@d3fc/d3fc-discontinuous-scale@4.1.1': - resolution: {integrity: sha512-cyhtq4XPtK8RCSBtzctRAl4RkDyYJqJY0SJpi3QcfJz/OX9iB6At7UjITO7tAmJ7RUHb/xZvAVpJU8BM5gaQqg==} - peerDependencies: - d3-scale: '*' - d3-time: '*' - - '@d3fc/d3fc-element@6.2.0': - resolution: {integrity: sha512-AvdZ3V4mVxF9dGYLiDCoqr3GhrFOUQEc1FcP20QEhQ3fJ3qYRwx7/uhL7G/L2xbe6k4delPgnLOvtoaDenhpZw==} - - '@d3fc/d3fc-extent@4.0.2': - resolution: {integrity: sha512-m7w7Dof6KAIDtgzIsTcprWTEoiqExJGsGoQbb97bF+EwIkuEZWRUl1jkoeNL00efpX1o6zSdqSr6lojoR0aI/g==} - peerDependencies: - d3-array: '*' - - '@d3fc/d3fc-financial-feed@7.1.0': - resolution: {integrity: sha512-K8jktdRJQAiJepglErsuY2ZMKsm0YFWTeuhYnTFb8rWmyhwoPeem9QW+e6xBTiAvbElJm4yTrkal09KmO2cLlQ==} - peerDependencies: - d3-fetch: '*' - - '@d3fc/d3fc-group@3.0.1': - resolution: {integrity: sha512-GBUR6a4hkqfSo77iaFS4qPMS5tupH8hmJ8eniiD45GFmWQs69+Dlf8Uhx+GCCvQkE+px6rQyZWVCJKBq6gkz2Q==} - - '@d3fc/d3fc-label-layout@7.0.4': - resolution: {integrity: sha512-4CCyOx6uQA/Eq1VHnNy9dpI5QE2sDd1EP4W0Nw2rwnhFtlQqv37V38b9y5zT4YcdFrEgJqSB81fXIt3ZK1yxSg==} - peerDependencies: - d3-array: '*' - d3-scale: '*' - d3-selection: '*' - - '@d3fc/d3fc-pointer@3.0.3': - resolution: {integrity: sha512-hXY7LqliDEJBH/do4YZusdLoikLYlWoN7efPC7YKYJ8igoEQFa48BqEHcANLs1qH+r7vzL2SS8V39MzgnJ1yQA==} - peerDependencies: - d3-dispatch: '*' - d3-selection: '*' - - '@d3fc/d3fc-random-data@4.0.2': - resolution: {integrity: sha512-T7+PbG1n23jyVMOWIuHJjY12PhPcYeRShuF0MK0ohifcNmwslurFMQKLBWkZk4x19In6JX7lNR0lwsaUUrcZNA==} - peerDependencies: - d3-random: '*' - d3-time: '*' - - '@d3fc/d3fc-rebind@6.0.1': - resolution: {integrity: sha512-+ryBZ53ALMffbADwnFAtTYQJcT7PE5BwpducGYS0X6Jux6ESnp+fP+cDQvBGbDBOVqaziGnfeLeJXjtMnZujmQ==} - - '@d3fc/d3fc-sample@5.0.2': - resolution: {integrity: sha512-+ZJu+TOL4MFzFvAYc+QqDKude9DS7x4uK+133vCknAlSxL/l8Sh6ecWFXTSaC8tqHGywT7SK1arf0DiYCyS3Nw==} - peerDependencies: - d3-array: '*' - - '@d3fc/d3fc-series@6.1.3': - resolution: {integrity: sha512-OSbt60SohTIib1xihX9ufneyJY7s9Feg9hvXVyEBZEBkwNE1NaeesZJ4nkmslu39RWuwsvpK3apL/XuBw7i5WA==} - peerDependencies: - d3-array: '*' - d3-scale: '*' - d3-scale-chromatic: '*' - d3-selection: '*' - d3-shape: '*' - - '@d3fc/d3fc-shape@6.0.1': - resolution: {integrity: sha512-/dD3S8BWrOjO2mSptUmwe38V7KG4Kw6liIE5NXZJjX/XidfZhuDu7WWuya3i90HeNYDZNcs6Z+4qM3FnvlZf8g==} - peerDependencies: - d3-path: '*' - - '@d3fc/d3fc-technical-indicator@8.1.1': - resolution: {integrity: sha512-ci5q+/4jCbbnT9M/JnrsBoHJfNJI4HFeCGVT+sy221OTdaFnrLj+IELFKFUS2h0uEOSu+CYT3D1K0BcAnMooxA==} - peerDependencies: - d3-array: '*' - - '@d3fc/d3fc-webgl@3.2.1': - resolution: {integrity: sha512-yNYHW/tC05rrJs5fpVM5zdmInL8NH6UP09ZJ2tmKw62ELYHWySV8vuXOVZN3V/3WihPa60p2w23H0Hrp2b5qeg==} - peerDependencies: - d3-scale: '*' - d3-shape: '*' - - '@d3fc/d3fc-zoom@1.2.0': - resolution: {integrity: sha512-NfY6gPfcatw1gUtZoWq17SA7LV00t7Rl3eq6Bgj17++7K1H77Gpsb1hFa3XhzF85RrODuUzQW33/IIMYPgim9A==} - peerDependencies: - d3-dispatch: '*' - d3-selection: '*' - d3-zoom: '*' - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@lumino/algorithm@2.0.4': - resolution: {integrity: sha512-gddBhESPqu25KWLeAK9Kz8tS9Ph7P45i0CNG7Ia4XMhK9PHLtTsBdJTC9jP+MqhbzC8zDT/4ekvYRV9ojRPj7Q==} - - '@lumino/collections@2.0.4': - resolution: {integrity: sha512-D/Py9L5HET6+XUYGxFqDEEth4B65X2c7B/GQVRR8q5Fl7EArVL6e98ZXw8BMkuPcTNa0zlENpCKXzlcoJZxXgQ==} - - '@lumino/commands@2.3.3': - resolution: {integrity: sha512-7Ci0QdFzt4NKFMhULr19sJPpOLHJw/oYlq6Pb0/Kq1s05+cIoLimr5wiyjkbAlNoGO/8A8SEBGHy3uctZz6G3A==} - - '@lumino/coreutils@2.2.2': - resolution: {integrity: sha512-zaKJaK7rawPATn2BGHkbMrR6oK3s9PxNe9KreLwWF2dB4ZBHDiEmNLRyHRorfJ7XqVOEXAsAAj0jFn+qJPC/4Q==} - - '@lumino/disposable@2.1.5': - resolution: {integrity: sha512-hO9AkJK0oEGzxopuxI8LaZqwzSNwXJTGCdr5K4gh6al+zxpN7rOCh6Aq3zDxkIHJU4zybxv8r02ardx9XJsG3A==} - - '@lumino/domutils@2.0.4': - resolution: {integrity: sha512-naYGUQn3e0CLtz/tjKOZP8SOBg0SW7EguhkxLpNUXlVUvx7rVsfr0VI22FVL+jgI0FbxXpEkxpSMxtK73jxJAg==} - - '@lumino/dragdrop@2.1.8': - resolution: {integrity: sha512-5sBYkTka598+XsgjY2tWOC+WYCh9NEgx8RhLvQ3x+V182YhcpEXw38RWGQZyNpQ4m4vtQWKv42A26q+ae6sMwg==} - - '@lumino/keyboard@2.0.4': - resolution: {integrity: sha512-kIVkdSz8F5wtZr8hZp0CMX+E0eMCOnFH6XCT7j2UBQ80ERJHFy0eX+IbNo3dtRQ7+CcDhBV4hQquFNFa+/04QQ==} - - '@lumino/messaging@2.0.4': - resolution: {integrity: sha512-NbZnchAPOciSe9Qn/g6EzG0LRaw7bygFIXbCD440ZhzvugdBeAerwYhrA795jkXPNrrl3olp5AlO0cBB/XZNtg==} - - '@lumino/properties@2.0.4': - resolution: {integrity: sha512-XsL2qLZk+1FbfuTrkyjciI8PMDw3YcaBkqVQ+iv7OOJf9bUlrmTpCMY0Hu5d3hV2W3TWlRsdbvRRLEBJSKv0iA==} - - '@lumino/signaling@2.1.5': - resolution: {integrity: sha512-Wkx6WR45ynmKBlW0GBEoh4xk9+QluKr1JHuMftqcStBHSQBCnN54UKRRDbySXHGRhhx6p4neu7sGomgQSlQK8w==} - - '@lumino/virtualdom@2.0.4': - resolution: {integrity: sha512-7MFthA9KUsqZTGm/D98FZt1QupjIGyd3XyB4SIugn6DQAqhjBiyykCZydnRq3qmuMHybQel33dNIbHpzyNyQwA==} - - '@lumino/widgets@2.7.5': - resolution: {integrity: sha512-i11PlbTsZYIvC/uhcC4FeeLnu/7vveG8WzXFbxPunjT1yGjleqQIPlpMOAJ5d4PwCKqeM8LYttYke6ZOXvXDLA==} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@perspective-dev/client@4.3.0': - resolution: {integrity: sha512-LhVDyYzZrSmtuU04qX+MzpnA1BKPEW9nMi4RPE6y3KiGAWwFFqEFgZKF233U8/Fx3v2URCijcwmXn0qTVfoPMQ==} - - '@perspective-dev/server@4.3.0': - resolution: {integrity: sha512-UH3ADscynozVx42RF07DTBmPE/0PUwH+SS0cgvMLuLfBjtvnPm6msfOU0tHlgFnNZOHJO0q6RZ9WI5fD6wF07A==} - - '@perspective-dev/viewer-d3fc@4.3.0': - resolution: {integrity: sha512-Su0pcno2RLnzotnMGLBSlLsCL3qE2BSTtso6KcUXFNO+Cbx2jzTo+5hDk+kcZtDYbMpZk/a7p+NoUhltL5qOGA==} - - '@perspective-dev/viewer-datagrid@4.3.0': - resolution: {integrity: sha512-edzgMCzhXcB+gfh7SoPnjVjzQxd5UB7O4RUZq+fB9dgSqh7KkdwTGz4XxHLr8VHhDB9cb8mbTmaVT4Mk32b9AQ==} - - '@perspective-dev/viewer@4.3.0': - resolution: {integrity: sha512-zF2xRqk4DfemMJ0Y1CCFFQKSk6L+v/hcQCPs0jVUs3cwS73bOVRnz1Hl9V3rwpfXSviILS+66d2HIlllvyd2vg==} - - '@perspective-dev/workspace@4.3.0': - resolution: {integrity: sha512-sEdeyw0jcPj6l7hCdckDm4KfCP8PuoomLG0/FrMQASKYscMq+Z1RDjo2T+5YJWOezEFCjpJy284YHnr4Vq2guw==} - - '@sindresorhus/merge-streams@4.0.0': - resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} - engines: {node: '>=18'} - - '@types/d3-selection@1.0.10': - resolution: {integrity: sha512-mHICSFHpIwgTycsvgINYCwItk039eofbGRzVNdeUUtv0S2BD1vXFFUKaeMJN3ARbVl+hlsVOIwdzhzub5tjr6Q==} - - ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - basic-auth@2.0.1: - resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} - engines: {node: '>= 0.8'} - - bn.js@5.2.3: - resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} - - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - buffer-pipe@0.0.3: - resolution: {integrity: sha512-GlxfuD/NrKvCNs0Ut+7b1IHjylfdegMBxQIlZHj7bObKVQBxB5S84gtm2yu1mQ8/sSggceWBDPY0cPXgvX2MuA==} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chroma-js@3.2.0: - resolution: {integrity: sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==} - - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - copy-file@11.1.0: - resolution: {integrity: sha512-X8XDzyvYaA6msMyAM575CUoygY5b44QzLcGRKsK3MFmXcOvQa518dNPLsKYwkYsn72g3EiW+LE0ytd/FlqWmyw==} - engines: {node: '>=18'} - - corser@2.0.1: - resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==} - engines: {node: '>= 0.4.0'} - - cpy@13.2.1: - resolution: {integrity: sha512-/H2B3WW9gccZJKjKoDZsIrDU3MkkHlxgheT82hUbInC5fEdi4+54zyYpFueZT9pLfr5ObrtgN4MsYYrmTmHzeg==} - engines: {node: '>=20'} - - cross-spawn@6.0.6: - resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} - engines: {node: '>=4.8'} - - d3-array@1.0.1: - resolution: {integrity: sha512-VPS5OH5Xb43tkFkxHEc4r5yWhlDwST47zh1q+qvgTj7xB9xDXn+UEcofhvNC7s8gD55y9Q/MCSPSBUVvnzo3Dw==} - - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-axis@3.0.0: - resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} - engines: {node: '>=12'} - - d3-brush@3.0.0: - resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} - engines: {node: '>=12'} - - d3-chord@3.0.1: - resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} - engines: {node: '>=12'} - - d3-collection@1.0.7: - resolution: {integrity: sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==} - - d3-color@1.4.1: - resolution: {integrity: sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-contour@4.0.2: - resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} - engines: {node: '>=12'} - - d3-delaunay@6.0.4: - resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} - engines: {node: '>=12'} - - d3-dispatch@1.0.1: - resolution: {integrity: sha512-BRTp95mobTSKx8EtpOLbxXuYVtNNr0PmelkH9Uzg5cgcO5O1M0i3+2C0FeM2I95BwQoIlsuZXQTPIoIt5xOtmw==} - - d3-dispatch@3.0.1: - resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} - engines: {node: '>=12'} - - d3-drag@3.0.0: - resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} - engines: {node: '>=12'} - - d3-dsv@3.0.1: - resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} - engines: {node: '>=12'} - hasBin: true - - d3-ease@1.0.7: - resolution: {integrity: sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==} - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-fetch@3.0.1: - resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} - engines: {node: '>=12'} - - d3-force@3.0.0: - resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} - engines: {node: '>=12'} - - d3-format@1.0.2: - resolution: {integrity: sha512-VHFdLLjGkeGrRL8T/rlIIDhI3vvVX/oOTM/GaDJfB1sIb4dU5ZgiEjg3EeidJdQ/70u60tM015TSWa1gqqLRhg==} - - d3-format@3.1.2: - resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} - engines: {node: '>=12'} - - d3-geo@3.1.1: - resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} - engines: {node: '>=12'} - - d3-hierarchy@3.1.2: - resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} - engines: {node: '>=12'} - - d3-interpolate@1.4.0: - resolution: {integrity: sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-polygon@3.0.1: - resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} - engines: {node: '>=12'} - - d3-quadtree@3.0.1: - resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} - engines: {node: '>=12'} - - d3-random@3.0.1: - resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} - engines: {node: '>=12'} - - d3-scale-chromatic@3.1.0: - resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} - engines: {node: '>=12'} - - d3-scale@1.0.3: - resolution: {integrity: sha512-ah2Xqywu96gau2iET3T0ZTsu0/X0gfoB8vDTuZ1OaG5F0SgGJLXreBVBknSZf2HKnxjenRvFok3qY2FgY4RpFg==} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-selection@1.0.2: - resolution: {integrity: sha512-nInNdsdhljkDqkU/83bdWwtiJ7xsX3l57YZMlqsAOMeQROeCv7osPqQgYnao0NmRZEGc11hNakY+EOkaIdsWpQ==} - - d3-selection@3.0.0: - resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} - engines: {node: '>=12'} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-svg-legend@2.25.6: - resolution: {integrity: sha512-6dueSjQr3+g9SlQ1SOzc4V58cCjjBeyo4WEcY8PW80i9XD/s562W/4xk05bpky0vzQx+i2XmXj3CYT+9KIRlnw==} - - d3-time-format@2.3.0: - resolution: {integrity: sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@1.1.0: - resolution: {integrity: sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@1.0.10: - resolution: {integrity: sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - - d3-transition@1.0.3: - resolution: {integrity: sha512-Facxcbma0nA2GVrx7B/Mgnn5ju6SwUMzGa9YcYmQjpqmaIq1Zbp5vVJLjtH6b08Lu0vcX7O6a4z+AlLmdCxrCQ==} - - d3-transition@3.0.1: - resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} - engines: {node: '>=12'} - peerDependencies: - d3-selection: 2 - 3 - - d3-zoom@3.0.0: - resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} - engines: {node: '>=12'} - - d3@7.9.0: - resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} - engines: {node: '>=12'} - - d3fc@15.2.13: - resolution: {integrity: sha512-wfBvY9TcoeV/bRxPjawlzDHS/r2y03STspyZc9ydiz4ANMt+y+ynL9oahTZ4aAbp5JK850VxWNkQ8jaaN/cDYg==} - - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - delaunator@5.1.0: - resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - - es-abstract@1.24.0: - resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - globby@16.1.1: - resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==} - engines: {node: '>=20'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - gradient-parser@1.2.0: - resolution: {integrity: sha512-6ABGa9CR7WR/0pAJicBy5SJkiikbFM6kf/JjykwX7x+t+s8ORWVnlbi6FkHeFFb36yWsjUpHqSYrygd7ofEUqA==} - engines: {node: '>=0.10.0'} - - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - - has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} - hasBin: true - - hosted-git-info@2.8.9: - resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} - - html-encoding-sniffer@3.0.0: - resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} - engines: {node: '>=12'} - - http-proxy@1.18.1: - resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} - engines: {node: '>=8.0.0'} - - http-server@14.1.1: - resolution: {integrity: sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==} - engines: {node: '>=12'} - hasBin: true - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-path-inside@4.0.0: - resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} - engines: {node: '>=12'} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - json-parse-better-errors@1.0.2: - resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} - - junk@4.0.1: - resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==} - engines: {node: '>=12.20'} - - leb128@0.0.5: - resolution: {integrity: sha512-elbNtfmu3GndZbesVF6+iQAfVjOXW9bM/aax9WwMlABZW+oK9sbAZEXoewaPHmL34sxa8kVwWsru8cNE/yn2gg==} - - lightningcss-android-arm64@1.33.0: - resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.33.0: - resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.33.0: - resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.33.0: - resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.33.0: - resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.33.0: - resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.33.0: - resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.33.0: - resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.33.0: - resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.33.0: - resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.33.0: - resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.33.0: - resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} - engines: {node: '>= 12.0.0'} - - load-json-file@4.0.0: - resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} - engines: {node: '>=4'} - - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - memorystream@0.3.1: - resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} - engines: {node: '>= 0.10.0'} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mkdirp@3.0.1: - resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} - engines: {node: '>=10'} - hasBin: true - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nice-try@1.0.5: - resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} - - normalize-package-data@2.5.0: - resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} - - npm-run-all@4.1.5: - resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} - engines: {node: '>= 4'} - hasBin: true - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - opener@1.5.2: - resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} - hasBin: true - - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - - p-event@6.0.1: - resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} - engines: {node: '>=16.17'} - - p-filter@4.1.0: - resolution: {integrity: sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==} - engines: {node: '>=18'} - - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} - - p-timeout@6.1.4: - resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} - engines: {node: '>=14.16'} - - parse-json@4.0.0: - resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} - engines: {node: '>=4'} - - path-key@2.0.1: - resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} - engines: {node: '>=4'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-type@3.0.0: - resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} - engines: {node: '>=4'} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - pidtree@0.3.1: - resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} - engines: {node: '>=0.10'} - hasBin: true - - pify@3.0.0: - resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} - engines: {node: '>=4'} - - portfinder@1.0.38: - resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} - engines: {node: '>= 10.12'} - - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} - engines: {node: '>=14'} - hasBin: true - - pro_self_extracting_wasm@0.0.9: - resolution: {integrity: sha512-95/dZfLmlGc/6Xp7gqvRBgXF8M+osw/Xtalz1U/Va8MpSC1TiR7rM4lEvAs1p/q4v/EZk6bow3tKEclbMGsSFQ==} - hasBin: true - - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} - engines: {node: '>=0.6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - read-pkg@3.0.0: - resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} - engines: {node: '>=4'} - - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - - regular-table@0.8.3: - resolution: {integrity: sha512-GANAV656dyTza89S1pz1NwQdcIZv+uUyV5z9k2mPnVfQxWVY150Ft75p35El0ICGpKvUG9mz6BP5vq4+d9BwUg==} - engines: {node: '>=16'} - - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - robust-predicates@3.0.3: - resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - rw@1.3.3: - resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} - - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - secure-compare@3.0.1: - resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} - - semver@5.7.2: - resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} - hasBin: true - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - shebang-command@1.2.0: - resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} - engines: {node: '>=0.10.0'} - - shebang-regex@1.0.0: - resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} - engines: {node: '>=0.10.0'} - - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} - engines: {node: '>= 0.4'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - slash@5.1.0: - resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} - engines: {node: '>=14.16'} - - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-license-ids@3.0.22: - resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} - - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - - stoppable@1.1.0: - resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} - engines: {node: '>=4', npm: '>=6'} - - string.prototype.padend@3.1.6: - resolution: {integrity: sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==} - engines: {node: '>= 0.4'} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - - unicorn-magic@0.4.0: - resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} - engines: {node: '>=20'} - - union@0.5.0: - resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} - engines: {node: '>= 0.8.0'} - - url-join@4.0.1: - resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} - - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - - whatwg-encoding@2.0.0: - resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} - engines: {node: '>=12'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} - engines: {node: '>= 0.4'} - - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true - - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - zx@8.8.5: - resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==} - engines: {node: '>= 12.17.0'} - hasBin: true - -snapshots: - - '@d3fc/d3fc-annotation@3.0.16(d3-array@3.2.4)(d3-path@3.1.0)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0)': - dependencies: - '@d3fc/d3fc-data-join': 6.0.3(d3-selection@3.0.0) - '@d3fc/d3fc-rebind': 6.0.1 - '@d3fc/d3fc-series': 6.1.3(d3-array@3.2.4)(d3-path@3.1.0)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0) - '@d3fc/d3fc-shape': 6.0.1(d3-path@3.1.0) - d3-scale: 4.0.2 - d3-selection: 3.0.0 - transitivePeerDependencies: - - d3-array - - d3-path - - d3-scale-chromatic - - d3-shape - - '@d3fc/d3fc-axis@3.0.7(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0)': - dependencies: - '@d3fc/d3fc-data-join': 6.0.3(d3-selection@3.0.0) - '@d3fc/d3fc-rebind': 6.0.1 - d3-scale: 4.0.2 - d3-selection: 3.0.0 - d3-shape: 3.2.0 - - '@d3fc/d3fc-brush@3.0.3(d3-brush@3.0.0)(d3-dispatch@3.0.1)(d3-scale@4.0.2)(d3-selection@3.0.0)': - dependencies: - '@d3fc/d3fc-data-join': 6.0.3(d3-selection@3.0.0) - '@d3fc/d3fc-rebind': 6.0.1 - d3-brush: 3.0.0 - d3-dispatch: 3.0.1 - d3-scale: 4.0.2 - d3-selection: 3.0.0 - - '@d3fc/d3fc-chart@5.1.9(d3-array@3.2.4)(d3-path@3.1.0)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0)': - dependencies: - '@d3fc/d3fc-axis': 3.0.7(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0) - '@d3fc/d3fc-data-join': 6.0.3(d3-selection@3.0.0) - '@d3fc/d3fc-element': 6.2.0 - '@d3fc/d3fc-rebind': 6.0.1 - '@d3fc/d3fc-series': 6.1.3(d3-array@3.2.4)(d3-path@3.1.0)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0) - d3-scale: 4.0.2 - d3-selection: 3.0.0 - transitivePeerDependencies: - - d3-array - - d3-path - - d3-scale-chromatic - - d3-shape - - '@d3fc/d3fc-data-join@6.0.3(d3-selection@3.0.0)': - dependencies: - d3-selection: 3.0.0 - - '@d3fc/d3fc-discontinuous-scale@4.1.1(d3-scale@4.0.2)(d3-time@3.1.0)': - dependencies: - '@d3fc/d3fc-rebind': 6.0.1 - d3-scale: 4.0.2 - d3-time: 3.1.0 - - '@d3fc/d3fc-element@6.2.0': {} - - '@d3fc/d3fc-extent@4.0.2(d3-array@3.2.4)': - dependencies: - d3-array: 3.2.4 - - '@d3fc/d3fc-financial-feed@7.1.0(d3-fetch@3.0.1)': - dependencies: - d3-fetch: 3.0.1 - - '@d3fc/d3fc-group@3.0.1': {} - - '@d3fc/d3fc-label-layout@7.0.4(d3-array@3.2.4)(d3-scale@4.0.2)(d3-selection@3.0.0)': - dependencies: - '@d3fc/d3fc-data-join': 6.0.3(d3-selection@3.0.0) - '@d3fc/d3fc-rebind': 6.0.1 - d3-array: 3.2.4 - d3-scale: 4.0.2 - d3-selection: 3.0.0 - - '@d3fc/d3fc-pointer@3.0.3(d3-dispatch@3.0.1)(d3-selection@3.0.0)': - dependencies: - '@d3fc/d3fc-rebind': 6.0.1 - d3-dispatch: 3.0.1 - d3-selection: 3.0.0 - - '@d3fc/d3fc-random-data@4.0.2(d3-random@3.0.1)(d3-time@3.1.0)': - dependencies: - '@d3fc/d3fc-rebind': 6.0.1 - d3-random: 3.0.1 - d3-time: 3.1.0 - - '@d3fc/d3fc-rebind@6.0.1': {} - - '@d3fc/d3fc-sample@5.0.2(d3-array@3.2.4)': - dependencies: - '@d3fc/d3fc-rebind': 6.0.1 - d3-array: 3.2.4 - - '@d3fc/d3fc-series@6.1.3(d3-array@3.2.4)(d3-path@3.1.0)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0)': - dependencies: - '@d3fc/d3fc-data-join': 6.0.3(d3-selection@3.0.0) - '@d3fc/d3fc-rebind': 6.0.1 - '@d3fc/d3fc-shape': 6.0.1(d3-path@3.1.0) - '@d3fc/d3fc-webgl': 3.2.1(d3-scale@4.0.2)(d3-shape@3.2.0) - d3-array: 3.2.4 - d3-scale: 4.0.2 - d3-scale-chromatic: 3.1.0 - d3-selection: 3.0.0 - d3-shape: 3.2.0 - transitivePeerDependencies: - - d3-path - - '@d3fc/d3fc-shape@6.0.1(d3-path@3.1.0)': - dependencies: - d3-path: 3.1.0 - - '@d3fc/d3fc-technical-indicator@8.1.1(d3-array@3.2.4)': - dependencies: - '@d3fc/d3fc-rebind': 6.0.1 - d3-array: 3.2.4 - - '@d3fc/d3fc-webgl@3.2.1(d3-scale@4.0.2)(d3-shape@3.2.0)': - dependencies: - '@d3fc/d3fc-rebind': 6.0.1 - d3-scale: 4.0.2 - d3-shape: 3.2.0 - - '@d3fc/d3fc-zoom@1.2.0(d3-dispatch@3.0.1)(d3-selection@3.0.0)(d3-zoom@3.0.0)': - dependencies: - '@d3fc/d3fc-rebind': 6.0.1 - d3-dispatch: 3.0.1 - d3-selection: 3.0.0 - d3-zoom: 3.0.0 - - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@lumino/algorithm@2.0.4': {} - - '@lumino/collections@2.0.4': - dependencies: - '@lumino/algorithm': 2.0.4 - - '@lumino/commands@2.3.3': - dependencies: - '@lumino/algorithm': 2.0.4 - '@lumino/coreutils': 2.2.2 - '@lumino/disposable': 2.1.5 - '@lumino/domutils': 2.0.4 - '@lumino/keyboard': 2.0.4 - '@lumino/signaling': 2.1.5 - '@lumino/virtualdom': 2.0.4 - - '@lumino/coreutils@2.2.2': - dependencies: - '@lumino/algorithm': 2.0.4 - - '@lumino/disposable@2.1.5': - dependencies: - '@lumino/signaling': 2.1.5 - - '@lumino/domutils@2.0.4': {} - - '@lumino/dragdrop@2.1.8': - dependencies: - '@lumino/coreutils': 2.2.2 - '@lumino/disposable': 2.1.5 - - '@lumino/keyboard@2.0.4': {} - - '@lumino/messaging@2.0.4': - dependencies: - '@lumino/algorithm': 2.0.4 - '@lumino/collections': 2.0.4 - - '@lumino/properties@2.0.4': {} - - '@lumino/signaling@2.1.5': - dependencies: - '@lumino/algorithm': 2.0.4 - '@lumino/coreutils': 2.2.2 - - '@lumino/virtualdom@2.0.4': - dependencies: - '@lumino/algorithm': 2.0.4 - - '@lumino/widgets@2.7.5': - dependencies: - '@lumino/algorithm': 2.0.4 - '@lumino/commands': 2.3.3 - '@lumino/coreutils': 2.2.2 - '@lumino/disposable': 2.1.5 - '@lumino/domutils': 2.0.4 - '@lumino/dragdrop': 2.1.8 - '@lumino/keyboard': 2.0.4 - '@lumino/messaging': 2.0.4 - '@lumino/properties': 2.0.4 - '@lumino/signaling': 2.1.5 - '@lumino/virtualdom': 2.0.4 - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@perspective-dev/client@4.3.0': - dependencies: - '@perspective-dev/server': 4.3.0 - pro_self_extracting_wasm: 0.0.9 - stoppable: 1.1.0 - ws: 8.20.0 - transitivePeerDependencies: - - bufferutil - - debug - - supports-color - - utf-8-validate - - '@perspective-dev/server@4.3.0': {} - - '@perspective-dev/viewer-d3fc@4.3.0(d3-brush@3.0.0)(d3-dispatch@3.0.1)(d3-fetch@3.0.1)(d3-path@3.1.0)(d3-random@3.0.1)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-shape@3.2.0)(d3-time@3.1.0)(d3-zoom@3.0.0)': - dependencies: - '@d3fc/d3fc-chart': 5.1.9(d3-array@3.2.4)(d3-path@3.1.0)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0) - '@d3fc/d3fc-element': 6.2.0 - '@perspective-dev/client': 4.3.0 - '@perspective-dev/viewer': 4.3.0 - chroma-js: 3.2.0 - d3: 7.9.0 - d3-array: 3.2.4 - d3-color: 3.1.0 - d3-selection: 3.0.0 - d3-svg-legend: 2.25.6 - d3fc: 15.2.13(d3-array@3.2.4)(d3-brush@3.0.0)(d3-dispatch@3.0.1)(d3-fetch@3.0.1)(d3-path@3.1.0)(d3-random@3.0.1)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0)(d3-time@3.1.0)(d3-zoom@3.0.0) - gradient-parser: 1.2.0 - transitivePeerDependencies: - - bufferutil - - d3-brush - - d3-dispatch - - d3-fetch - - d3-path - - d3-random - - d3-scale - - d3-scale-chromatic - - d3-shape - - d3-time - - d3-zoom - - debug - - supports-color - - utf-8-validate - - '@perspective-dev/viewer-datagrid@4.3.0': - dependencies: - '@perspective-dev/client': 4.3.0 - '@perspective-dev/viewer': 4.3.0 - chroma-js: 3.2.0 - regular-table: 0.8.3 - transitivePeerDependencies: - - bufferutil - - debug - - supports-color - - utf-8-validate - - '@perspective-dev/viewer@4.3.0': - dependencies: - '@perspective-dev/client': 4.3.0 - pro_self_extracting_wasm: 0.0.9 - transitivePeerDependencies: - - bufferutil - - debug - - supports-color - - utf-8-validate - - '@perspective-dev/workspace@4.3.0': - dependencies: - '@lumino/algorithm': 2.0.4 - '@lumino/commands': 2.3.3 - '@lumino/coreutils': 2.2.2 - '@lumino/domutils': 2.0.4 - '@lumino/messaging': 2.0.4 - '@lumino/signaling': 2.1.5 - '@lumino/virtualdom': 2.0.4 - '@lumino/widgets': 2.7.5 - '@perspective-dev/client': 4.3.0 - lodash: 4.18.1 - transitivePeerDependencies: - - bufferutil - - debug - - supports-color - - utf-8-validate - - '@sindresorhus/merge-streams@4.0.0': {} - - '@types/d3-selection@1.0.10': {} - - ansi-styles@3.2.1: - dependencies: - color-convert: 1.9.3 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - - async-function@1.0.0: {} - - async@3.2.6: {} - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - balanced-match@1.0.2: {} - - basic-auth@2.0.1: - dependencies: - safe-buffer: 5.1.2 - - bn.js@5.2.3: {} - - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - buffer-pipe@0.0.3: - dependencies: - safe-buffer: 5.2.1 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - chalk@2.4.2: - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 5.5.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chroma-js@3.2.0: {} - - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.3: {} - - color-name@1.1.4: {} - - commander@7.2.0: {} - - concat-map@0.0.1: {} - - copy-file@11.1.0: - dependencies: - graceful-fs: 4.2.11 - p-event: 6.0.1 - - corser@2.0.1: {} - - cpy@13.2.1: - dependencies: - copy-file: 11.1.0 - globby: 16.1.1 - junk: 4.0.1 - micromatch: 4.0.8 - p-filter: 4.1.0 - p-map: 7.0.4 - - cross-spawn@6.0.6: - dependencies: - nice-try: 1.0.5 - path-key: 2.0.1 - semver: 5.7.2 - shebang-command: 1.2.0 - which: 1.3.1 - - d3-array@1.0.1: {} - - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-axis@3.0.0: {} - - d3-brush@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3-chord@3.0.1: - dependencies: - d3-path: 3.1.0 - - d3-collection@1.0.7: {} - - d3-color@1.4.1: {} - - d3-color@3.1.0: {} - - d3-contour@4.0.2: - dependencies: - d3-array: 3.2.4 - - d3-delaunay@6.0.4: - dependencies: - delaunator: 5.1.0 - - d3-dispatch@1.0.1: {} - - d3-dispatch@3.0.1: {} - - d3-drag@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-selection: 3.0.0 - - d3-dsv@3.0.1: - dependencies: - commander: 7.2.0 - iconv-lite: 0.6.3 - rw: 1.3.3 - - d3-ease@1.0.7: {} - - d3-ease@3.0.1: {} - - d3-fetch@3.0.1: - dependencies: - d3-dsv: 3.0.1 - - d3-force@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-quadtree: 3.0.1 - d3-timer: 3.0.1 - - d3-format@1.0.2: {} - - d3-format@3.1.2: {} - - d3-geo@3.1.1: - dependencies: - d3-array: 3.2.4 - - d3-hierarchy@3.1.2: {} - - d3-interpolate@1.4.0: - dependencies: - d3-color: 1.4.1 - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@3.1.0: {} - - d3-polygon@3.0.1: {} - - d3-quadtree@3.0.1: {} - - d3-random@3.0.1: {} - - d3-scale-chromatic@3.1.0: - dependencies: - d3-color: 3.1.0 - d3-interpolate: 3.0.1 - - d3-scale@1.0.3: - dependencies: - d3-array: 1.0.1 - d3-collection: 1.0.7 - d3-color: 1.4.1 - d3-format: 1.0.2 - d3-interpolate: 1.4.0 - d3-time: 1.1.0 - d3-time-format: 2.3.0 - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.2 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-selection@1.0.2: {} - - d3-selection@3.0.0: {} - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-svg-legend@2.25.6: - dependencies: - '@types/d3-selection': 1.0.10 - d3-array: 1.0.1 - d3-dispatch: 1.0.1 - d3-format: 1.0.2 - d3-scale: 1.0.3 - d3-selection: 1.0.2 - d3-transition: 1.0.3 - - d3-time-format@2.3.0: - dependencies: - d3-time: 1.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@1.1.0: {} - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@1.0.10: {} - - d3-timer@3.0.1: {} - - d3-transition@1.0.3: - dependencies: - d3-color: 1.4.1 - d3-dispatch: 1.0.1 - d3-ease: 1.0.7 - d3-interpolate: 1.4.0 - d3-selection: 1.0.2 - d3-timer: 1.0.10 - - d3-transition@3.0.1(d3-selection@3.0.0): - dependencies: - d3-color: 3.1.0 - d3-dispatch: 3.0.1 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-timer: 3.0.1 - - d3-zoom@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) - - d3@7.9.0: - dependencies: - d3-array: 3.2.4 - d3-axis: 3.0.0 - d3-brush: 3.0.0 - d3-chord: 3.0.1 - d3-color: 3.1.0 - d3-contour: 4.0.2 - d3-delaunay: 6.0.4 - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-dsv: 3.0.1 - d3-ease: 3.0.1 - d3-fetch: 3.0.1 - d3-force: 3.0.0 - d3-format: 3.1.2 - d3-geo: 3.1.1 - d3-hierarchy: 3.1.2 - d3-interpolate: 3.0.1 - d3-path: 3.1.0 - d3-polygon: 3.0.1 - d3-quadtree: 3.0.1 - d3-random: 3.0.1 - d3-scale: 4.0.2 - d3-scale-chromatic: 3.1.0 - d3-selection: 3.0.0 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - d3-timer: 3.0.1 - d3-transition: 3.0.1(d3-selection@3.0.0) - d3-zoom: 3.0.0 - - d3fc@15.2.13(d3-array@3.2.4)(d3-brush@3.0.0)(d3-dispatch@3.0.1)(d3-fetch@3.0.1)(d3-path@3.1.0)(d3-random@3.0.1)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0)(d3-time@3.1.0)(d3-zoom@3.0.0): - dependencies: - '@d3fc/d3fc-annotation': 3.0.16(d3-array@3.2.4)(d3-path@3.1.0)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0) - '@d3fc/d3fc-axis': 3.0.7(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0) - '@d3fc/d3fc-brush': 3.0.3(d3-brush@3.0.0)(d3-dispatch@3.0.1)(d3-scale@4.0.2)(d3-selection@3.0.0) - '@d3fc/d3fc-chart': 5.1.9(d3-array@3.2.4)(d3-path@3.1.0)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0) - '@d3fc/d3fc-data-join': 6.0.3(d3-selection@3.0.0) - '@d3fc/d3fc-discontinuous-scale': 4.1.1(d3-scale@4.0.2)(d3-time@3.1.0) - '@d3fc/d3fc-element': 6.2.0 - '@d3fc/d3fc-extent': 4.0.2(d3-array@3.2.4) - '@d3fc/d3fc-financial-feed': 7.1.0(d3-fetch@3.0.1) - '@d3fc/d3fc-group': 3.0.1 - '@d3fc/d3fc-label-layout': 7.0.4(d3-array@3.2.4)(d3-scale@4.0.2)(d3-selection@3.0.0) - '@d3fc/d3fc-pointer': 3.0.3(d3-dispatch@3.0.1)(d3-selection@3.0.0) - '@d3fc/d3fc-random-data': 4.0.2(d3-random@3.0.1)(d3-time@3.1.0) - '@d3fc/d3fc-rebind': 6.0.1 - '@d3fc/d3fc-sample': 5.0.2(d3-array@3.2.4) - '@d3fc/d3fc-series': 6.1.3(d3-array@3.2.4)(d3-path@3.1.0)(d3-scale-chromatic@3.1.0)(d3-scale@4.0.2)(d3-selection@3.0.0)(d3-shape@3.2.0) - '@d3fc/d3fc-shape': 6.0.1(d3-path@3.1.0) - '@d3fc/d3fc-technical-indicator': 8.1.1(d3-array@3.2.4) - '@d3fc/d3fc-webgl': 3.2.1(d3-scale@4.0.2)(d3-shape@3.2.0) - '@d3fc/d3fc-zoom': 1.2.0(d3-dispatch@3.0.1)(d3-selection@3.0.0)(d3-zoom@3.0.0) - transitivePeerDependencies: - - d3-array - - d3-brush - - d3-dispatch - - d3-fetch - - d3-path - - d3-random - - d3-scale - - d3-scale-chromatic - - d3-selection - - d3-shape - - d3-time - - d3-zoom - - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - delaunator@5.1.0: - dependencies: - robust-predicates: 3.0.3 - - detect-libc@2.1.2: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - es-abstract@1.24.0: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.19 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - - escape-string-regexp@1.0.5: {} - - eventemitter3@4.0.7: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - follow-redirects@1.16.0: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - - function-bind@1.1.2: {} - - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - - generator-function@2.0.1: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - globby@16.1.1: - dependencies: - '@sindresorhus/merge-streams': 4.0.0 - fast-glob: 3.3.3 - ignore: 7.0.5 - is-path-inside: 4.0.0 - slash: 5.1.0 - unicorn-magic: 0.4.0 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - gradient-parser@1.2.0: {} - - has-bigints@1.1.0: {} - - has-flag@3.0.0: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - he@1.2.0: {} - - hosted-git-info@2.8.9: {} - - html-encoding-sniffer@3.0.0: - dependencies: - whatwg-encoding: 2.0.0 - - http-proxy@1.18.1: - dependencies: - eventemitter3: 4.0.7 - follow-redirects: 1.16.0 - requires-port: 1.0.0 - transitivePeerDependencies: - - debug - - http-server@14.1.1: - dependencies: - basic-auth: 2.0.1 - chalk: 4.1.2 - corser: 2.0.1 - he: 1.2.0 - html-encoding-sniffer: 3.0.0 - http-proxy: 1.18.1 - mime: 1.6.0 - minimist: 1.2.8 - opener: 1.5.2 - portfinder: 1.0.38 - secure-compare: 3.0.1 - union: 0.5.0 - url-join: 4.0.1 - transitivePeerDependencies: - - debug - - supports-color - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - ignore@7.0.5: {} - - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 - - internmap@2.0.3: {} - - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-arrayish@0.2.1: {} - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-callable@1.2.7: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-extglob@2.1.1: {} - - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-number@7.0.0: {} - - is-path-inside@4.0.0: {} - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.19 - - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - isarray@2.0.5: {} - - isexe@2.0.0: {} - - json-parse-better-errors@1.0.2: {} - - junk@4.0.1: {} - - leb128@0.0.5: - dependencies: - bn.js: 5.2.3 - buffer-pipe: 0.0.3 - - lightningcss-android-arm64@1.33.0: - optional: true - - lightningcss-darwin-arm64@1.33.0: - optional: true - - lightningcss-darwin-x64@1.33.0: - optional: true - - lightningcss-freebsd-x64@1.33.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.33.0: - optional: true - - lightningcss-linux-arm64-gnu@1.33.0: - optional: true - - lightningcss-linux-arm64-musl@1.33.0: - optional: true - - lightningcss-linux-x64-gnu@1.33.0: - optional: true - - lightningcss-linux-x64-musl@1.33.0: - optional: true - - lightningcss-win32-arm64-msvc@1.33.0: - optional: true - - lightningcss-win32-x64-msvc@1.33.0: - optional: true - - lightningcss@1.33.0: - dependencies: - detect-libc: 2.1.2 - 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 - - load-json-file@4.0.0: - dependencies: - graceful-fs: 4.2.11 - parse-json: 4.0.0 - pify: 3.0.0 - strip-bom: 3.0.0 - - lodash@4.18.1: {} - - math-intrinsics@1.1.0: {} - - memorystream@0.3.1: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mime@1.6.0: {} - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.13 - - minimist@1.2.8: {} - - mkdirp@3.0.1: {} - - ms@2.1.3: {} - - nice-try@1.0.5: {} - - normalize-package-data@2.5.0: - dependencies: - hosted-git-info: 2.8.9 - resolve: 1.22.11 - semver: 5.7.2 - validate-npm-package-license: 3.0.4 - - npm-run-all@4.1.5: - dependencies: - ansi-styles: 3.2.1 - chalk: 2.4.2 - cross-spawn: 6.0.6 - memorystream: 0.3.1 - minimatch: 3.1.5 - pidtree: 0.3.1 - read-pkg: 3.0.0 - shell-quote: 1.8.3 - string.prototype.padend: 3.1.6 - - object-inspect@1.13.4: {} - - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - opener@1.5.2: {} - - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - - p-event@6.0.1: - dependencies: - p-timeout: 6.1.4 - - p-filter@4.1.0: - dependencies: - p-map: 7.0.4 - - p-map@7.0.4: {} - - p-timeout@6.1.4: {} - - parse-json@4.0.0: - dependencies: - error-ex: 1.3.4 - json-parse-better-errors: 1.0.2 - - path-key@2.0.1: {} - - path-parse@1.0.7: {} - - path-type@3.0.0: - dependencies: - pify: 3.0.0 - - picomatch@2.3.2: {} - - pidtree@0.3.1: {} - - pify@3.0.0: {} - - portfinder@1.0.38: - dependencies: - async: 3.2.6 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - possible-typed-array-names@1.1.0: {} - - prettier@3.8.3: {} - - pro_self_extracting_wasm@0.0.9: - dependencies: - http-server: 14.1.1 - leb128: 0.0.5 - zx: 8.8.5 - transitivePeerDependencies: - - debug - - supports-color - - qs@6.15.0: - dependencies: - side-channel: 1.1.0 - - queue-microtask@1.2.3: {} - - read-pkg@3.0.0: - dependencies: - load-json-file: 4.0.0 - normalize-package-data: 2.5.0 - path-type: 3.0.0 - - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - - regular-table@0.8.3: {} - - requires-port@1.0.0: {} - - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - - robust-predicates@3.0.3: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - rw@1.3.3: {} - - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-buffer@5.1.2: {} - - safe-buffer@5.2.1: {} - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - - safer-buffer@2.1.2: {} - - secure-compare@3.0.1: {} - - semver@5.7.2: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - - shebang-command@1.2.0: - dependencies: - shebang-regex: 1.0.0 - - shebang-regex@1.0.0: {} - - shell-quote@1.8.3: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - slash@5.1.0: {} - - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.22 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.22 - - spdx-license-ids@3.0.22: {} - - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 - - stoppable@1.1.0: {} - - string.prototype.padend@3.1.6: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 - - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - strip-bom@3.0.0: {} - - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - - unicorn-magic@0.4.0: {} - - union@0.5.0: - dependencies: - qs: 6.15.0 - - url-join@4.0.1: {} - - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - - whatwg-encoding@2.0.0: - dependencies: - iconv-lite: 0.6.3 - - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.19 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.19: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - - which@1.3.1: - dependencies: - isexe: 2.0.0 - - ws@8.20.0: {} - - zx@8.8.5: {} diff --git a/js/pnpm-workspace.yaml b/js/pnpm-workspace.yaml deleted file mode 100644 index 5ed0b5a..0000000 --- a/js/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -allowBuilds: - esbuild: true diff --git a/js/src/index.css b/js/src/index.css deleted file mode 100644 index 1fc9493..0000000 --- a/js/src/index.css +++ /dev/null @@ -1,44 +0,0 @@ -@import "perspective-viewer-pro.css"; -@import "perspective-viewer-pro-dark.css"; -@import "perspective-viewer-monokai.css"; -@import "perspective-viewer-vaporwave.css"; -@import "perspective-viewer-solarized.css"; -@import "perspective-viewer-solarized-dark.css"; -@import "perspective-viewer-gruvbox.css"; -@import "perspective-viewer-gruvbox-dark.css"; - -html { - font-family: "Roboto Mono"; -} - -perspective-workspace { - position: absolute; - top: 40px; - bottom: 0; - left: 0; - right: 0; -} - -.title-bar { - position: absolute; - top: 0; - left: 0; - right: 0; - height: 40px; - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 10px; - z-index: 1000; - border-bottom: 1px solid #ccc; -} - -.title { - font-size: 18px; - font-weight: bold; -} -.save-button { - padding: 5px 10px; - border: none; - cursor: pointer; -} diff --git a/js/src/index.html b/js/src/index.html deleted file mode 100644 index 74a54bc..0000000 --- a/js/src/index.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - raydar - - - - - - - - - - - -
- Raydar v{{ version }} - -
- - - diff --git a/js/src/index.js b/js/src/index.js deleted file mode 100644 index 82cffda..0000000 --- a/js/src/index.js +++ /dev/null @@ -1,107 +0,0 @@ -import perspective from "@perspective-dev/client"; -import perspective_viewer from "@perspective-dev/viewer"; -import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm"; -import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm"; - -import "@perspective-dev/workspace"; -import "@perspective-dev/viewer-datagrid"; -import "@perspective-dev/viewer-d3fc"; - -const perspective_init_promise = Promise.all([ - perspective.init_server(fetch(SERVER_WASM)), - perspective_viewer.init_client(fetch(CLIENT_WASM)), -]); - -function removeTrailingSlash(url) { - return url.replace(/\/$/, ""); -} - -async function load() { - await perspective_init_promise; - const workspace = document.querySelector("perspective-workspace"); - const saveButton = document.getElementById("save-layout-button"); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const websocket = await perspective.websocket( - `${protocol}//${window.location.host}${removeTrailingSlash( - window.location.pathname, - )}/ws`, - ); - const registeredTables = new Set(); - - const updateTables = async () => { - const response = await fetch( - `${removeTrailingSlash(window.location.href)}/tables`, - ); - const tables = await response.json(); - - tables.map(async (tableName) => { - if (registeredTables.has(tableName)) return; - console.log(`Registering table: ${tableName}`); - registeredTables.add(tableName); - await workspace.addTable( - tableName, - await websocket.open_table(tableName), - ); - }); - }; - - document.body.addEventListener("dragover", (e) => { - e.preventDefault(); - }); - - document.body.addEventListener("drop", (e) => { - e.preventDefault(); - - const file = e.dataTransfer.files[0]; - if (file) { - const reader = new FileReader(); - reader.onload = (event) => { - try { - const json = JSON.parse(event.target.result); - workspace.restore(json); - console.log("File contents:", json); - } catch (error) { - console.error("Error parsing JSON:", error); - } - }; - reader.readAsText(file); - } - }); - - saveButton.addEventListener("click", function () { - let workspace = document.querySelector("perspective-workspace"); - - workspace.save().then((config) => { - // Convert the configuration object to a JSON string - let json = JSON.stringify(config); - - // Create a Blob object from the JSON string - let blob = new Blob([json], { type: "application/json" }); - - // Create a download link - let link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = "workspace.json"; - - // Append the link to the document body and click it to start the download - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }); - }); - - const layouts = await fetch("static/layouts/default.json"); - const layoutData = await layouts.json(); - console.log("Loading layout from static/layouts/default.json..."); - - if (Object.keys(layoutData).length > 0) { - await workspace.restore(layoutData); - } - - await updateTables(); - - // update tables every 5s - setInterval(updateTables, 5000); -} - -load(); diff --git a/js/src/layouts/default.json b/js/src/layouts/default.json deleted file mode 100644 index 0967ef4..0000000 --- a/js/src/layouts/default.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/js/tools/build.js b/js/tools/build.js deleted file mode 100644 index d95f072..0000000 --- a/js/tools/build.js +++ /dev/null @@ -1,38 +0,0 @@ -import esbuild from "esbuild"; - -const CUTOFF_PERCENT = 0.02; - -const DEFAULT_BUILD = { - target: ["es2022"], - bundle: true, - minify: !process.env.PSP_DEBUG, - sourcemap: true, - metafile: true, - entryNames: "[name]", - chunkNames: "[name]", - assetNames: "[name]", -}; - -export const build = async (config) => { - const result = await esbuild.build({ - ...DEFAULT_BUILD, - ...config, - }); - - if (result.metafile) { - for (const output of Object.keys(result.metafile.outputs)) { - const { inputs, bytes } = result.metafile.outputs[output]; - for (const input of Object.keys(inputs)) { - if (inputs[input].bytesInOutput / bytes < CUTOFF_PERCENT) { - delete inputs[input]; - } - } - } - - const text = await esbuild.analyzeMetafile(result.metafile, { - color: true, - }); - - console.log(text); - } -}; diff --git a/js/tools/css.js b/js/tools/css.js deleted file mode 100644 index cb68059..0000000 --- a/js/tools/css.js +++ /dev/null @@ -1,38 +0,0 @@ -import { bundleAsync } from "lightningcss"; -import { getarg } from "./getarg.js"; -import fs from "fs"; -import path from "path"; - -const DEBUG = getarg("--debug"); - -const DEFAULT_RESOLVER = { - resolve(specifier, originatingFile) { - if (/^https?:\/\//.test(specifier)) { - return specifier; - } - - if (specifier.startsWith("perspective-viewer-")) { - const viewerCssDir = path.resolve( - "node_modules/@perspective-dev/viewer/dist/css", - ); - const normalized = specifier.replace(/^perspective-viewer-/, ""); - const normalizedPath = path.join(viewerCssDir, normalized); - if (fs.existsSync(normalizedPath)) { - return normalizedPath; - } - return path.join(viewerCssDir, specifier); - } - return path.resolve(path.dirname(originatingFile), specifier); - }, -}; - -export const compile = async (root = "src/index.css", resolver = null) => { - const { code } = await bundleAsync({ - filename: path.resolve(root), - minify: !DEBUG, - sourceMap: false, - resolver: resolver || DEFAULT_RESOLVER, - }); - fs.mkdirSync("./dist", { recursive: true }); - fs.writeFileSync("./dist/index.css", code); -} diff --git a/js/tools/getarg.js b/js/tools/getarg.js deleted file mode 100644 index 9cfe88c..0000000 --- a/js/tools/getarg.js +++ /dev/null @@ -1,23 +0,0 @@ -export const getarg = (flag, ...args) => { - if (Array.isArray(flag)) { - flag = flag.map((x, i) => x + (args[i] || "")).join(""); - } - const argv = process.argv.slice(2); - if (flag) { - const index = argv.indexOf(flag); - if (index > -1) { - const next = argv[index + 1]; - if (next) { - return next; - } else { - return true; - } - } - } else { - return argv - .map(function (arg) { - return "'" + arg.replace(/'/g, "'\\''") + "'"; - }) - .join(" "); - } - }; diff --git a/pyproject.toml b/pyproject.toml index d562542..10e3929 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,6 @@ [build-system] requires = [ "hatchling", - "hatch-js", ] build-backend = "hatchling.build" @@ -10,11 +9,11 @@ name = "raydar" authors = [ {name = "Point72, L.P.", email = "OpenSource@point72.com"}, ] -description = "A perspective powered, user editable ray dashboard via ray serve" +description = "A perspective powered, user editable ray dashboard" readme = "README.md" license = { text = "Apache-2.0" } version = "0.3.0" -requires-python = ">=3.10" +requires-python = ">=3.11" keywords = [ "perspective", "ray", @@ -31,7 +30,6 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -40,14 +38,18 @@ classifiers = [ dependencies = [ "coolname", - "fastapi<0.139.2", - "jinja2", - "packaging", - "perspective-python>=3.4,<3.5", + "perspective-python>=4.5,<4.6", "polars", "pyarrow", - "pydantic", + "pydantic>=2", "ray[serve]>=2.8", + "spaday>=0.7,<0.8", + "spaday-perspective>=0.2,<0.3", + "spaday-webawesome>=0.2,<0.3", + "starlette", + "transports>=0.8,<0.9", + "uvicorn", + "websockets", ] [project.optional-dependencies] @@ -56,8 +58,8 @@ develop = [ "bump-my-version", "check-dist", "codespell", - "hatch-js", "hatchling", + "httpx", "mdformat", "mdformat-tables>=1", "pytest", @@ -95,16 +97,6 @@ filename = "pyproject.toml" search = 'version = "{current_version}"' replace = 'version = "{new_version}"' -[[tool.bumpversion.files]] -filename = "js/package.json" -search = '"version": "{current_version}"' -replace = '"version": "{new_version}"' - -[[tool.bumpversion.files]] -filename = "js/src/index.html" -search = 'Raydar v{current_version}' -replace = 'Raydar v{new_version}' - [tool.coverage.run] branch = true omit = [ @@ -120,39 +112,18 @@ exclude_also = [ ignore_errors = true fail_under = 40 -[tool.hatch.build] -artifacts = [ - "raydar/dashboard/static" -] - [tool.hatch.build.sources] src = "/" [tool.hatch.build.targets.sdist] packages = [ "raydar", - "js", -] -exclude = [ - "js/dist", - "js/node_modules", ] [tool.hatch.build.targets.wheel] packages = [ "raydar", ] -exclude = [ - "js", -] - -[tool.hatch.build.hooks.hatch-js] -path = "js" -build_cmd = "build" -tool = "pnpm" -targets = [ - "raydar/dashboard/static/index.js", -] [tool.pytest.ini_options] addopts = [ diff --git a/raydar/dashboard/__init__.py b/raydar/dashboard/__init__.py index 02a2992..738887f 100644 --- a/raydar/dashboard/__init__.py +++ b/raydar/dashboard/__init__.py @@ -1 +1,5 @@ -from .server import PerspectiveProxyRayServer, PerspectiveRayServer +from .dashboard import Dashboard, TableHost +from .local import LocalDashboard +from .state import DashboardState, default_layout + +__all__ = ("Dashboard", "DashboardState", "LocalDashboard", "TableHost", "default_layout") diff --git a/raydar/dashboard/dashboard.py b/raydar/dashboard/dashboard.py new file mode 100644 index 0000000..ee51223 --- /dev/null +++ b/raydar/dashboard/dashboard.py @@ -0,0 +1,148 @@ +"""Perspective tables plus the spaday app that renders them. + +A :class:`Dashboard` owns everything the browser talks to: the Perspective +server holding the tables, the transports session holding the UI state, and the +Starlette app that serves the page. It is deliberately free of any Ray +dependency so it can run in the caller's process, in a Ray Serve replica, or +standalone. +""" + +import asyncio +from collections.abc import Awaitable, Callable, Sequence +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from typing import Any + +import perspective +import transports +from perspective.handlers.starlette import PerspectiveStarletteHandler +from spaday import Wire +from spaday.backends.starlette import serve as spaday_serve +from spaday_perspective import package as perspective_package +from spaday_webawesome import package as webawesome_package +from starlette.routing import WebSocketRoute +from starlette.websockets import WebSocket, WebSocketDisconnect + +from .. import __version__ +from .page import STYLES, build_page +from .state import DashboardState, default_layout + +__all__ = ("Dashboard", "TableHost") + + +class TableHost: + """Owns a Perspective server and the tables served over its websocket.""" + + def __init__(self, limit: int | None = None): + self.server = perspective.Server() + self._client = self.server.new_local_client() + self._limit = limit + self._schemas: dict[str, dict] = {} + self._tables: dict[str, Any] = {} + self.total_rows = 0 + + def names(self) -> list[str]: + return list(self._schemas) + + def new_table(self, tablename: str, schema: dict) -> None: + if tablename in self._schemas: + return + self._schemas[tablename] = schema + kwargs = {"name": tablename} + if self._limit is not None: + kwargs["limit"] = self._limit + self._tables[tablename] = self._client.table(schema, **kwargs) + + def update(self, tablename: str, data) -> None: + if isinstance(data, dict): + data = [data] + if tablename not in self._tables: + raise KeyError(f"No such table: {tablename}") + self._tables[tablename].update(data) + self.total_rows += len(data) + + def clear(self, tablename: str) -> None: + if tablename in self._tables: + self._tables[tablename].clear() + + +class Dashboard: + """The raydar UI: Perspective tables, UI state, and the Starlette app. + + Args: + title: Page title and brand text. + limit: Optional per-table row cap, applied to every table created. + layout: A perspective-workspace layout to use instead of the generated + one-tab-per-table default. + background: Factories for coroutines to run for the lifetime of the app. + Factories rather than coroutines so nothing is created for a + dashboard that is never served. + """ + + def __init__( + self, + title: str = "raydar", + limit: int | None = None, + layout: dict | None = None, + background: Sequence[Callable[[], Awaitable]] = (), + ): + self.tables = TableHost(limit=limit) + self.state = DashboardState() + self._layout_override = layout + + self._session = transports.Session() + self._session.host(self.state) + self._transport = transports.Server(self._session) + + @asynccontextmanager + async def lifespan(_app): + factories = (lambda: transports.autosync(self._transport), *background) + tasks = [asyncio.ensure_future(factory()) for factory in factories] + try: + yield + finally: + for task in tasks: + task.cancel() + + self.app = spaday_serve( + build_page(title, __version__), + title=title, + packages=[perspective_package, webawesome_package], + # spaday infers "source" from a `js/` dir next to its package, which some + # unrelated wheels create in site-packages. raydar always consumes the + # packaged assets, so say so rather than rely on the heuristic. + layout="installed", + wire=[Wire("/ws", namespace="rd", flatten=False)], + routes=[ + WebSocketRoute("/ws", transports.ws_endpoint(self._transport)), + WebSocketRoute("/perspective", self._perspective_socket), + ], + lifespan=lifespan, + store={"dark": False}, + head=STYLES, + ) + + async def _perspective_socket(self, websocket: WebSocket) -> None: + try: + await PerspectiveStarletteHandler(perspective_server=self.tables.server, websocket=websocket).run() + except WebSocketDisconnect: + pass + + def apply(self, batch: dict) -> None: + """Apply a drained :class:`~raydar.ops.OpBuffer` batch to the tables.""" + for tablename, schema in (batch.get("schemas") or {}).items(): + self.tables.new_table(tablename, schema) + for tablename in batch.get("cleared") or (): + self.tables.clear(tablename) + for tablename, rows in (batch.get("updates") or {}).items(): + self.tables.update(tablename, rows) + self._refresh_state() + + def _refresh_state(self) -> None: + names = self.tables.names() + if names != self.state.tables: + self.state.tables = names + self.state.layout = self._layout_override or default_layout(names) + self.state.rows = f"{self.tables.total_rows:,}" + self.state.status = "Live" if names else "Waiting for data" + self.state.updated = datetime.now(tz=UTC).astimezone().strftime("%H:%M:%S") diff --git a/raydar/dashboard/demo.py b/raydar/dashboard/demo.py index b1ea80b..d94e567 100644 --- a/raydar/dashboard/demo.py +++ b/raydar/dashboard/demo.py @@ -1,48 +1,52 @@ -import os +"""A runnable example of the local dashboard: `python -m raydar.dashboard.demo`. + +The dashboard is served from this process and pulls updates from the tracker +actor over Ray, so the cluster needs no inbound port. +""" + import random import time import ray -from .server import PerspectiveProxyRayServer, PerspectiveRayServer +from ..task_tracker import RayTaskTracker + +TABLE = "demo" +SCHEMA = { + "start": "datetime", + "end": "datetime", + "runtime": "float", + "backoff": "float", + "random": "float", +} @ray.remote -def test_job(backoff, tablename, proxy): +def demo_job(backoff: float) -> dict: start = time.time() time.sleep(backoff) end = time.time() - runtime = end - start - data = {"start": start, "end": end, "runtime": runtime, "backoff": backoff, "random": random.random()} - proxy.remote("update", tablename, data) - return data + return { + "start": start, + "end": end, + "runtime": end - start, + "backoff": backoff, + "random": random.random(), + } if __name__ == "__main__": - os.environ["RAY_SERVE_ENABLE_EXPERIMENTAL_STREAMING"] = "1" - - host = "127.0.0.1" # NOTE: change if you run on another machine - port = 8989 - ray.init(dashboard_host=host, dashboard_port=port) - ray.serve.start(http_options={"host": host, "port": port + 1}) - - webserver = ray.serve.run(PerspectiveRayServer.bind(), name="webserver", route_prefix="/") - proxy_server = ray.serve.run(PerspectiveProxyRayServer.bind(webserver), name="proxy", route_prefix="/proxy") - - # setup perspective table - proxy_server.remote( - "new", - "data", - { - "start": "datetime", - "end": "datetime", - "runtime": "float", - "backoff": "float", - "random": "float", - }, - ) - - # launch jobs - while True: - test_job.remote(backoff=random.random(), tablename="data", proxy=proxy_server) - time.sleep(0.5) + ray.init() + + task_tracker = RayTaskTracker(namespace="raydar-demo", dashboard="local") + task_tracker.create_table(TABLE, SCHEMA) + print(f"raydar dashboard: {task_tracker.dashboard_url}") + + try: + while True: + ref = demo_job.remote(backoff=random.random()) + task_tracker.process([ref]) + task_tracker.update_table(TABLE, [ray.get(ref)]) + time.sleep(0.5) + except KeyboardInterrupt: + task_tracker.exit() diff --git a/raydar/dashboard/local.py b/raydar/dashboard/local.py new file mode 100644 index 0000000..517c2e7 --- /dev/null +++ b/raydar/dashboard/local.py @@ -0,0 +1,96 @@ +"""Run the dashboard in the caller's process. + +This is the mode that needs no inbound port on the Ray cluster. The web server +binds to the caller's machine and pulls table operations from the tracker actor +over the connection Ray already holds, so the browser only ever talks to +localhost and the cluster only ever accepts Ray traffic. +""" + +import asyncio +import logging +import socket +import threading +from collections.abc import Callable + +import uvicorn + +from .dashboard import Dashboard + +logger = logging.getLogger(__name__) + +__all__ = ("LocalDashboard",) + + +class LocalDashboard: + """A :class:`~raydar.dashboard.dashboard.Dashboard` served from a background thread. + + Args: + drain: A callable returning the next batch of table operations, or None + when there is nothing to apply. Called from a worker thread, so it + may block. + host: Interface to bind. Defaults to loopback. + port: Port to bind, or 0 to let the OS pick a free one. + poll_interval: Seconds between calls to ``drain``. + **kwargs: Forwarded to :class:`~raydar.dashboard.dashboard.Dashboard`. + """ + + def __init__( + self, + drain: Callable[[], dict | None], + host: str = "127.0.0.1", + port: int = 0, + poll_interval: float = 0.5, + **kwargs, + ): + self._drain = drain + self._host = host + self._poll_interval = poll_interval + self._thread: threading.Thread | None = None + + # Bind up front so `url` is accurate before the server thread starts. + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._socket.bind((host, port)) + self._socket.listen(128) + self.port = self._socket.getsockname()[1] + + self.dashboard = Dashboard(background=[self._poll], **kwargs) + config = uvicorn.Config(self.dashboard.app, log_level="warning", lifespan="on") + self._server = uvicorn.Server(config) + # uvicorn installs signal handlers, which is only legal on the main thread. + self._server.install_signal_handlers = lambda: None + + @property + def url(self) -> str: + return f"http://{self._host}:{self.port}" + + async def _poll(self) -> None: + while True: + try: + batch = await asyncio.to_thread(self._drain) + except Exception: + logger.exception("Failed to fetch dashboard updates") + batch = None + if batch: + self.dashboard.apply(batch) + await asyncio.sleep(self._poll_interval) + + def start(self) -> str: + """Start serving in a daemon thread and return the dashboard URL.""" + if self._thread is not None: + return self.url + self._thread = threading.Thread( + target=lambda: asyncio.run(self._server.serve(sockets=[self._socket])), + name="raydar-dashboard", + daemon=True, + ) + self._thread.start() + logger.info(f"raydar dashboard serving at {self.url}") + return self.url + + def stop(self) -> None: + """Ask the server to exit and wait for the thread to finish.""" + self._server.should_exit = True + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None diff --git a/raydar/dashboard/page.py b/raydar/dashboard/page.py new file mode 100644 index 0000000..35ed09e --- /dev/null +++ b/raydar/dashboard/page.py @@ -0,0 +1,64 @@ +"""The raydar page, authored as a spaday component tree.""" + +from spaday import cond, element, field, obj +from spaday_perspective import PerspectivePanel +from spaday_webawesome import WaBadge, WaSwitch + +__all__ = ("STYLES", "build_page") + +STYLES = """ + +""" + + +def build_page(title: str, version: str): + """Build the component tree. + + ``rd.*`` fields come from the transports-hosted :class:`~raydar.dashboard.state.DashboardState`; + ``dark`` is a client-only store field, so the theme toggle needs no round trip. + """ + panel = ( + PerspectivePanel(id="raydar-workspace") + .compute("theme", cond(field("dark"), "dark", "light")) + .compute( + "config", + obj({"ws_url": "/perspective", "tables": field("rd.tables"), "layout": field("rd.layout")}), + ) + ) + + header = element( + "header", + element("span", class_="rd-brand").text(title), + element("span", class_="rd-version").text(f"v{version}"), + element( + "div", + WaBadge(variant="brand").bind("textContent", "rd.status"), + element("span").bind("textContent", "rd.rows"), + element("span").text("rows"), + element("span", class_="rd-version").bind("textContent", "rd.updated"), + WaSwitch().bind("checked", "dark", mode="two-way"), + element("span").text("Dark"), + class_="rd-metrics", + ), + class_="rd-header", + ) + + return element( + "div", + header, + element("section", panel, class_="rd-workspace"), + ).compute("class", cond(field("dark"), "rd wa-dark", "rd")) diff --git a/raydar/dashboard/serve.py b/raydar/dashboard/serve.py new file mode 100644 index 0000000..82df5d2 --- /dev/null +++ b/raydar/dashboard/serve.py @@ -0,0 +1,32 @@ +"""Serve the dashboard from inside the Ray cluster. + +This mode requires an inbound port on the cluster, which is what +:mod:`raydar.dashboard.local` exists to avoid. It remains available for +clusters that already expose Ray Serve's HTTP ingress. +""" + +from ray.serve import deployment, ingress + +from .dashboard import Dashboard + +__all__ = ("RaydarDeployment",) + + +@deployment(name="raydar_dashboard", num_replicas=1) +@ingress() +class RaydarDeployment: + """A single replica owning the Perspective tables and the spaday app. + + The app is built by ``__serve_build_asgi_app__`` rather than handed to + ``ingress``: it holds a Perspective server and a transports store, neither + of which survives the pickling Ray Serve does to ship an app to a replica. + """ + + def __init__(self, title: str = "raydar", limit: int | None = None, layout: dict | None = None): + self.dashboard = Dashboard(title=title, limit=limit, layout=layout) + + def __serve_build_asgi_app__(self): + return self.dashboard.app + + async def apply(self, batch: dict) -> None: + self.dashboard.apply(batch) diff --git a/raydar/dashboard/server.py b/raydar/dashboard/server.py deleted file mode 100644 index b645ac3..0000000 --- a/raydar/dashboard/server.py +++ /dev/null @@ -1,131 +0,0 @@ -import logging -from os import environ -from os.path import abspath, dirname, join -from traceback import format_exc - -import perspective -from fastapi import FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from perspective.handlers.starlette import PerspectiveStarletteHandler -from pydantic import BaseModel, Field -from ray.serve import Application, deployment, ingress - -from .. import __version__ - - -class PerspectiveRayServerArgs(BaseModel): - name: str = Field(default="Perspective") - - -app = FastAPI() -logger = logging.getLogger("ray.serve") -static_files_dir = join(abspath(dirname(__file__)), "static") -templates = Jinja2Templates(static_files_dir) -app.mount("/static", StaticFiles(directory=static_files_dir, check_dir=False, html=True)) - - -@deployment(name="Perspective_Web_Server", num_replicas=1) -@ingress(app) -class PerspectiveRayServer: - def __init__(self, args: PerspectiveRayServerArgs = None): - logger.setLevel(logging.ERROR) - args = args or PerspectiveRayServerArgs() - self._schemas = {} - self._tables = {} - - def new_table(self, tablename: str, schema) -> None: - if tablename in self._schemas: - return self._schemas[tablename] - self._schemas[tablename] = schema - self._tables[tablename] = perspective.table(schema, name=tablename) - - def clear_table(self, tablename: str, schema) -> None: - if tablename in self._tables: - self._tables[tablename].clear() - - def update(self, tablename: str, data): - if isinstance(data, dict): - data = [data] - self._tables[tablename].update(data) - - @app.websocket("/ws") - async def ws(self, ws: WebSocket): - handler = PerspectiveStarletteHandler(websocket=ws) - try: - await handler.run() - except WebSocketDisconnect: - ... - - @app.get("/") - async def site(self, request: Request): - return templates.TemplateResponse(request=request, name="index.html", context={"version": __version__, "javascript": "index.js"}) - - @app.get("/version") - async def version(self): - return __version__ - - @app.get("/tables") - async def tables(self): - return list(self._schemas.keys()) - - @app.post("/new/{tablename}") - async def new_table_rest(self, tablename: str, request: Request) -> Response: - if tablename in self._schemas: - raise HTTPException(501, "Table already exists, replace not yet supported") - try: - schema = await request.json() - except BaseException as exception: - raise HTTPException(503, "Exception during schema parsing") from exception - - try: - self.new_table(tablename, schema) - return Response(content=schema) - except BaseException as exception: - raise HTTPException( - 503, - f"Exception during table creation: {schema} / {tablename} / {format_exc()}", - ) from exception - - @app.get("/get/{tablename}") - async def get_table_rest(self, tablename: str): - if tablename not in self._schemas: - raise HTTPException(404, "Table does not exist: {tablename}") - return Response(content=self._schemas[tablename]) - - @app.post("/update/{tablename}") - async def update_table_rest(self, tablename: str, request: Request) -> Response: - if tablename not in self._schemas: - # just create table for now - self.new_table(tablename, []) - try: - data = await request.json() - except BaseException as exception: - raise HTTPException(503, "Exception during data parsing") from exception - - try: - self.update(tablename, data) - except BaseException as exception: - raise HTTPException(503, f"Exception during data ingestion: {tablename} / {format_exc()}") from exception - - -@deployment(name="Perspective_Proxy_Server") -class PerspectiveProxyRayServer: - def __init__(self, psp_handle): - logger.setLevel(logging.ERROR) - self._psp_handle = psp_handle - - def __call__(self, op, tablename, data_or_schema): - if op == "new" and tablename and data_or_schema: - self._psp_handle.new_table.remote(tablename, data_or_schema) - if op == "update" and tablename and data_or_schema: - self._psp_handle.update.remote(tablename, data_or_schema) - if op == "clear" and tablename: - self._psp_handle.clear_table.remote(tablename, data_or_schema) - - -def main(args: PerspectiveRayServerArgs = None) -> Application: - args = args or PerspectiveRayServerArgs() - if environ.get("RAY_SERVE_ENABLE_EXPERIMENTAL_STREAMING") is not None: - return PerspectiveProxyRayServer.bind(PerspectiveRayServer.bind(args)) - raise RuntimeError("Perspective server requires websockets, rerun with RAY_SERVE_ENABLE_EXPERIMENTAL_STREAMING=1") diff --git a/raydar/dashboard/state.py b/raydar/dashboard/state.py new file mode 100644 index 0000000..f2f5c56 --- /dev/null +++ b/raydar/dashboard/state.py @@ -0,0 +1,32 @@ +"""The model the browser mirrors over transports.""" + +from pydantic import BaseModel, Field + +__all__ = ("DashboardState", "default_layout") + + +class DashboardState(BaseModel): + """State pushed to every connected browser. + + Bulk table data rides Perspective's own websocket; only this summary and the + workspace layout travel over transports. + """ + + status: str = "Waiting for data" + tables: list[str] = Field(default_factory=list) + layout: dict = Field(default_factory=dict) + rows: str = "0" + updated: str = "" + + +def default_layout(tables: list[str]) -> dict: + """A perspective-workspace layout showing one datagrid tab per table.""" + if not tables: + return {} + return { + "sizes": [1], + "detail": {"main": {"type": "tab-area", "widgets": list(tables), "currentIndex": 0}}, + "master": {"sizes": [], "widgets": []}, + "mode": "globalFilters", + "viewers": {name: {"table": name, "plugin": "Datagrid", "title": name} for name in tables}, + } diff --git a/raydar/ops.py b/raydar/ops.py new file mode 100644 index 0000000..1a65dad --- /dev/null +++ b/raydar/ops.py @@ -0,0 +1,63 @@ +"""Serializable batches of table operations. + +The tracker actor and the dashboard may live in different processes, so table +creates, updates and clears are expressed as plain data rather than as calls +against a table handle. This module deliberately imports nothing heavy: it is +loaded inside cluster actors that never render a dashboard. +""" + +from collections import deque + +__all__ = ("OpBuffer",) + + +class OpBuffer: + """Accumulates table operations until a consumer drains them. + + Used when the dashboard runs outside the cluster and pulls over Ray's own + connection. ``schemas`` are replayed on every drain so a dashboard that + starts late still learns about tables created before it connected. + """ + + def __init__(self, max_rows_per_table: int = 100_000): + self._max_rows_per_table = max_rows_per_table + self._schemas: dict[str, dict] = {} + self._updates: dict[str, deque] = {} + self._cleared: list[str] = [] + + def new_table(self, tablename: str, schema: dict) -> None: + self._schemas.setdefault(tablename, schema) + + def update(self, tablename: str, data) -> None: + if isinstance(data, dict): + data = [data] + if not data: + return + rows = self._updates.get(tablename) + if rows is None: + rows = self._updates[tablename] = deque(maxlen=self._max_rows_per_table) + rows.extend(data) + + def clear(self, tablename: str) -> None: + self._updates.pop(tablename, None) + self._cleared.append(tablename) + + def extend(self, batch: dict) -> None: + """Absorb a batch in the same shape :meth:`drain` produces.""" + for tablename, schema in (batch.get("schemas") or {}).items(): + self.new_table(tablename, schema) + for tablename in batch.get("cleared") or (): + self.clear(tablename) + for tablename, rows in (batch.get("updates") or {}).items(): + self.update(tablename, rows) + + def drain(self) -> dict: + """Return the operations buffered since the last call and reset them.""" + batch = { + "schemas": dict(self._schemas), + "updates": {name: list(rows) for name, rows in self._updates.items() if rows}, + "cleared": list(self._cleared), + } + self._updates.clear() + self._cleared.clear() + return batch diff --git a/raydar/task_tracker/task_tracker.py b/raydar/task_tracker/task_tracker.py index 2dfbeab..f426ebb 100644 --- a/raydar/task_tracker/task_tracker.py +++ b/raydar/task_tracker/task_tracker.py @@ -1,49 +1,29 @@ import asyncio import itertools import logging -import os from collections.abc import Iterable +from typing import Literal import coolname import pandas as pd import polars as pl import ray -from packaging.version import Version from ray.serve import shutdown -from ray.serve.handle import DeploymentHandle +from ..ops import OpBuffer from .schema import schema as default_schema logger = logging.getLogger(__name__) -__all__ = ("AsyncMetadataTracker", "RayTaskTracker", "setup_proxy_server") +DashboardMode = Literal["local", "cluster"] + +__all__ = ("AsyncMetadataTracker", "RayTaskTracker") def get_callback_actor_name(name: str) -> str: return f"{name}_callback_actor" -def setup_proxy_server(proxy_server_name="proxy", proxy_server_route_prefix="/proxy", **kwargs) -> DeploymentHandle: - """Construct a webserver, and bind it to a PerspectiveProxyRayServer. - - Args: - proxy_server_name: the name passed to ray.serve.run for the PerspectiveProxyRayServer - proxy_server_route_prefix: the route_prefix passed to ray.serve.run for the PerspectiveProxyRayServer - **kwargs: arguments forwarded to ray.serve.run() for the webserver - - Returns: A DeploymentHandle for the PerspectiveProxyRayServer - """ - from raydar.dashboard.server import PerspectiveProxyRayServer - - webserver = ray.serve.run(**kwargs) - proxy_server = ray.serve.run( - PerspectiveProxyRayServer.bind(webserver), - name=proxy_server_name, - route_prefix=proxy_server_route_prefix, - ) - return proxy_server - - @ray.remote(resources={"node:__internal_head__": 0.1}, num_cpus=0) class AsyncMetadataTrackerCallback: """ @@ -87,7 +67,8 @@ def __init__( name: str, namespace: str, path: str | None = None, - enable_perspective_dashboard: bool = False, + dashboard: DashboardMode | None = None, + max_buffered_rows: int = 100_000, ): """An async Ray Actor Class to track task level metadata. @@ -99,8 +80,9 @@ def __init__( name: Ray actor name, used to construct its AsyncMetadataTrackerCallback actor attribute. namespace: Ray Namespace path: A Cloudpathlib.AnyPath, used for saving its internal polars DataFrame object. - enable_perspective_dashboard: To enable an experimental perspective dashboard. - + dashboard: "local" buffers table operations for a dashboard running outside the cluster to + drain; "cluster" pushes them to a Ray Serve deployment; None disables the dashboard. + max_buffered_rows: Per-table cap on rows held for a "local" dashboard to drain. """ logger.info(f"Initializing an AsyncMetadataTracker in namespace {namespace} with name {name}.") # Passing 'self' to the AsyncMetadataTrackerCallback converts this actor class to a @@ -116,9 +98,11 @@ def __init__( self.df = None self.finished_tasks = {} self.user_defined_metadata = {} - self.perspective_dashboard_enabled = enable_perspective_dashboard + self.dashboard_mode = dashboard self.pending_tasks = [] self.perspective_table_name = f"{name}_data" + self._buffer = None + self._handle = None # WARNING: Do not move this import. Importing these modules elsewhere can cause # difficult to diagnose, "There is no current event loop in thread 'ray_client_server_" errors. @@ -127,45 +111,57 @@ def __init__( self.client = StateApiClient(address=ray.get_runtime_context().gcs_address) - if self.perspective_dashboard_enabled: - from raydar.dashboard.server import PerspectiveRayServer - - kwargs = { - "target": PerspectiveRayServer.bind(), - "name": "webserver", - "route_prefix": "/", - } + if dashboard == "local": + self._buffer = OpBuffer(max_rows_per_table=max_buffered_rows) + elif dashboard == "cluster": + from raydar.dashboard.serve import RaydarDeployment - if Version(ray.__version__) < Version("2.10"): - kwargs["port"] = int(os.environ.get("RAYDAR_PORT", "8000")) + self._handle = ray.serve.run(RaydarDeployment.bind(), name="raydar", route_prefix="/") + elif dashboard is not None: + raise ValueError(f"Unknown dashboard mode: {dashboard!r}") - self.proxy_server = setup_proxy_server(**kwargs) - self.proxy_server.remote( - "new", - self.perspective_table_name, + if dashboard is not None: + self.emit( { - "task_id": "string", - "user_defined_metadata": "string", - "attempt_number": "integer", - "name": "string", - "state": "string", - "job_id": "string", - "actor_id": "float", - "type": "string", - "func_or_class_name": "string", - "parent_task_id": "string", - "node_id": "string", - "worker_id": "string", - "error_type": "string", - "language": "string", - "placement_group_id": "float", - "creation_time_ms": "datetime", - "start_time_ms": "datetime", - "end_time_ms": "datetime", - "error_message": "string", - }, + "schemas": { + self.perspective_table_name: { + "task_id": "string", + "user_defined_metadata": "string", + "attempt_number": "integer", + "name": "string", + "state": "string", + "job_id": "string", + "actor_id": "float", + "type": "string", + "func_or_class_name": "string", + "parent_task_id": "string", + "node_id": "string", + "worker_id": "string", + "error_type": "string", + "language": "string", + "placement_group_id": "float", + "creation_time_ms": "datetime", + "start_time_ms": "datetime", + "end_time_ms": "datetime", + "error_message": "string", + } + } + } ) + def emit(self, batch: dict) -> None: + """Route a batch of table operations to whichever dashboard is configured.""" + if self._buffer is not None: + self._buffer.extend(batch) + elif self._handle is not None: + self._handle.apply.remote(batch) + + def drain(self) -> dict | None: + """Return buffered table operations for a dashboard running outside the cluster.""" + if self._buffer is None: + return None + return self._buffer.drain() + def callback(self, tasks: Iterable[ray.ObjectRef]) -> None: """A remote function used by this actor's processor actor attribute. Will be called by a separate actor with a collection of ray object references once those ObjectReferences are not in the "RUNNING" or @@ -208,13 +204,11 @@ def metadata_filter(task) -> bool: for task, metadata in completed_tasks: self.finished_tasks[task.task_id().hex()] = metadata - if self.perspective_dashboard_enabled: - self.update_perspective_dashboard(completed_tasks) + if self.dashboard_mode is not None: + self.publish_tasks(completed_tasks) - def update_perspective_dashboard(self, completed_tasks) -> None: - """A helper function, which updates this actor's proxy_server attribute with processed data. - - That proxy_server serves perspective tables which anticipate the data formats we provide. + def publish_tasks(self, completed_tasks) -> None: + """Emit completed task metadata as rows for the dashboard's task table. Args: completed_tasks: A list of tuples of the form (ObjectReference, TaskMetadata), where the ObjectReferences are neither Running nor Pending Assignment. @@ -243,7 +237,7 @@ def update_perspective_dashboard(self, completed_tasks) -> None: } for task, metadata in completed_tasks ] - self.proxy_server.remote("update", self.perspective_table_name, data) + self.emit({"updates": {self.perspective_table_name: data}}) async def process(self, obj_refs: Iterable[ray.ObjectRef], metadata: Iterable[str] | None = None, chunk_size: int = 25_000) -> None: """An asynchronous function to process a collection of Ray object references. @@ -294,14 +288,6 @@ def get_df(self) -> pl.DataFrame: ) return self.df - def get_proxy_server(self) -> ray.serve.handle.DeploymentHandle: - """A getter for this actors proxy server attribute. Can be used to create custom perspective visuals. - Returns: this actors proxy_server attribute - """ - if self.proxy_server: - return self.proxy_server - raise RuntimeError("This task_tracker has no active proxy_server.") - def save_df(self) -> None: """Saves the internally maintained dataframe of task related information from the ray GCS""" self.get_df() @@ -315,19 +301,38 @@ def clear_df(self) -> None: """Clears the internally maintained dataframe of task related information from the ray GCS""" self.df = None self.finished_tasks = {} - if self.perspective_dashboard_enabled: - self.proxy_server.remote("clear", self.perspective_table_name, None) + if self.dashboard_mode is not None: + self.emit({"cleared": [self.perspective_table_name]}) class RayTaskTracker: - def __init__(self, name: str = "task_tracker", namespace: str | None = None, **kwargs): + def __init__( + self, + name: str = "task_tracker", + namespace: str | None = None, + dashboard: DashboardMode | None = None, + dashboard_host: str = "127.0.0.1", + dashboard_port: int = 0, + dashboard_options: dict | None = None, + poll_interval: float = 0.5, + **kwargs, + ): """A utility to construct AsyncMetadataTracker actors. Wraps several remote AsyncMetadataTracker functions in a ray.get() call for convenience. Args: - Optional[name]: The named used to construct a AsyncMetadataTracker, also used to form the name of its AsyncMetadataTrackerCallback. - Optional[namespace]: Ray namespace for the AsyncMetadataTracker and its AsyncMetadataTrackerCallback. + name: The name used to construct a AsyncMetadataTracker, also used to form the name of its AsyncMetadataTrackerCallback. + namespace: Ray namespace for the AsyncMetadataTracker and its AsyncMetadataTrackerCallback. + dashboard: "local" serves the dashboard from this process and pulls updates over Ray, + so the cluster needs no inbound port. "cluster" serves it from Ray Serve, which + does. None disables the dashboard. + dashboard_host: Interface the "local" dashboard binds. + dashboard_port: Port the "local" dashboard binds, or 0 to pick a free one. + dashboard_options: Forwarded to :class:`~raydar.dashboard.dashboard.Dashboard` + (``title``, ``layout``, ``limit``). + poll_interval: Seconds between "local" dashboard polls of the tracker actor. + **kwargs: Forwarded to the AsyncMetadataTracker actor. """ if namespace is None: namespace = coolname.generate_slug(2) @@ -335,6 +340,8 @@ def __init__(self, name: str = "task_tracker", namespace: str | None = None, **k self.name = name self.namespace = namespace + self.dashboard_mode = dashboard + self.dashboard = None self.tracker = AsyncMetadataTracker.options( lifetime="detached", name=name, @@ -343,9 +350,27 @@ def __init__(self, name: str = "task_tracker", namespace: str | None = None, **k ).remote( name=name, namespace=namespace, + dashboard=dashboard, **kwargs, ) + if dashboard == "local": + from raydar.dashboard import LocalDashboard + + self.dashboard = LocalDashboard( + drain=lambda: ray.get(self.tracker.drain.remote()), + host=dashboard_host, + port=dashboard_port, + poll_interval=poll_interval, + **(dashboard_options or {}), + ) + self.dashboard.start() + + @property + def dashboard_url(self) -> str | None: + """The URL of the local dashboard, or None when it is not running in this process.""" + return self.dashboard.url if self.dashboard else None + def process(self, object_refs: Iterable[ray.ObjectRef], metadata: Iterable[str] | None = None, chunk_size: int = 25_000) -> None: """A helper function, to send this object's AsyncMetadataTracker actor a collection of object references to track""" self.tracker.process.remote(object_refs, metadata=metadata, chunk_size=chunk_size) @@ -368,21 +393,18 @@ def clear(self) -> None: return ray.get(self.tracker.clear_df.remote()) def create_table(self, table_name: str, table_schema: dict[str, str]) -> None: - """Create a new perspective table using the proxy server used by the RayTaskTracker's AsyncMetadataTracker actor""" - proxy_server = self.proxy_server() - return proxy_server.remote("new", table_name, table_schema) + """Create a new perspective table on the dashboard""" + self.tracker.emit.remote({"schemas": {table_name: table_schema}}) def update_table(self, table_name: str, data: list[dict]) -> None: - """Update rows of perspective table held by the proxy server used by the RayTaskTracker's AsyncMetadataTracker actor""" - proxy_server = self.proxy_server() - return proxy_server.remote("update", table_name, data) - - def proxy_server(self) -> ray.serve.handle.DeploymentHandle: - """Fetch the proxy server used by this object's AsyncMetadataTracker actor""" - return ray.get(self.tracker.get_proxy_server.remote()) + """Append rows to a perspective table on the dashboard""" + self.tracker.emit.remote({"updates": {table_name: data}}) def exit(self) -> None: """Perform cleanup tasks, kill associated actors, and shutdown.""" + if self.dashboard is not None: + self.dashboard.stop() + self.dashboard = None ray.kill(ray.get_actor(name=self.name, namespace=self.namespace)) ray.kill(ray.get_actor(name=get_callback_actor_name(self.name), namespace=self.namespace)) shutdown() diff --git a/raydar/tests/test_dashboard.py b/raydar/tests/test_dashboard.py new file mode 100644 index 0000000..a10bdcf --- /dev/null +++ b/raydar/tests/test_dashboard.py @@ -0,0 +1,96 @@ +import json + +import pytest +from starlette.testclient import TestClient + +from raydar.dashboard import Dashboard, default_layout + +SCHEMA = {"a": "integer", "b": "string"} + + +@pytest.fixture +def dashboard(): + return Dashboard(title="test") + + +class TestDefaultLayout: + def test_no_tables_yields_an_empty_layout(self): + assert default_layout([]) == {} + + def test_each_table_gets_a_widget_and_a_viewer(self): + layout = default_layout(["x", "y"]) + assert layout["detail"]["main"]["widgets"] == ["x", "y"] + assert set(layout["viewers"]) == {"x", "y"} + assert layout["viewers"]["x"]["table"] == "x" + + +class TestDashboard: + def test_apply_creates_tables_and_counts_rows(self, dashboard): + dashboard.apply({"schemas": {"t": SCHEMA}, "updates": {"t": [{"a": 1, "b": "x"}]}}) + + assert dashboard.tables.names() == ["t"] + assert dashboard.state.tables == ["t"] + assert dashboard.state.rows == "1" + assert dashboard.state.status == "Live" + + def test_state_starts_empty(self, dashboard): + assert dashboard.state.tables == [] + assert dashboard.state.layout == {} + assert dashboard.state.status == "Waiting for data" + + def test_layout_follows_the_tables(self, dashboard): + dashboard.apply({"schemas": {"t": SCHEMA}}) + assert dashboard.state.layout == default_layout(["t"]) + + def test_layout_override_wins(self): + override = {"sizes": [1], "viewers": {}} + dashboard = Dashboard(layout=override) + dashboard.apply({"schemas": {"t": SCHEMA}}) + assert dashboard.state.layout == override + + def test_repeated_schemas_do_not_recreate_tables(self, dashboard): + dashboard.apply({"schemas": {"t": SCHEMA}, "updates": {"t": [{"a": 1, "b": "x"}]}}) + dashboard.apply({"schemas": {"t": SCHEMA}, "updates": {"t": [{"a": 2, "b": "y"}]}}) + assert dashboard.state.rows == "2" + + def test_clear_empties_the_table(self, dashboard): + dashboard.apply({"schemas": {"t": SCHEMA}, "updates": {"t": [{"a": 1, "b": "x"}]}}) + dashboard.apply({"cleared": ["t"]}) + assert dashboard.tables.names() == ["t"] + + def test_update_of_an_unknown_table_raises(self, dashboard): + with pytest.raises(KeyError): + dashboard.apply({"updates": {"nope": [{"a": 1}]}}) + + def test_limit_is_applied_to_new_tables(self): + dashboard = Dashboard(limit=2) + dashboard.apply({"schemas": {"t": SCHEMA}, "updates": {"t": [{"a": i, "b": "x"} for i in range(5)]}}) + assert dashboard.tables.names() == ["t"] + + +class TestDashboardApp: + def test_page_and_assets_are_served(self, dashboard): + with TestClient(dashboard.app) as client: + page = client.get("/") + assert page.status_code == 200 + # spaday's source/installed asset detection is pinned; a regression there 404s the runtime. + for asset in ("/js/cdn/index.js", "/components/perspective/cdn/index.js", "/components/webawesome/cdn/index.js"): + assert client.get(asset).status_code == 200, asset + + def test_tree_wires_the_panel_to_the_state_model(self, dashboard): + with TestClient(dashboard.app) as client: + tree = json.dumps(client.get("/tree.json").json()) + assert "perspective-panel" in tree + for path in ("rd.tables", "rd.layout", "rd.status"): + assert path in tree + + def test_state_is_pushed_over_the_transports_socket(self, dashboard): + dashboard.apply({"schemas": {"t": SCHEMA}}) + with TestClient(dashboard.app) as client, client.websocket_connect("/ws") as ws: + snapshot = json.loads(ws.receive_text()) + assert snapshot["t"] == "snapshot" + assert snapshot["type"] == "DashboardState" + + def test_perspective_has_its_own_socket(self, dashboard): + with TestClient(dashboard.app) as client, client.websocket_connect("/perspective") as ws: + assert ws is not None diff --git a/raydar/tests/test_ops.py b/raydar/tests/test_ops.py new file mode 100644 index 0000000..04b62ee --- /dev/null +++ b/raydar/tests/test_ops.py @@ -0,0 +1,63 @@ +import pytest + +from raydar.ops import OpBuffer + + +class TestOpBuffer: + def test_drain_returns_schemas_and_updates(self): + buffer = OpBuffer() + buffer.new_table("t", {"a": "integer"}) + buffer.update("t", [{"a": 1}, {"a": 2}]) + + batch = buffer.drain() + assert batch == {"schemas": {"t": {"a": "integer"}}, "updates": {"t": [{"a": 1}, {"a": 2}]}, "cleared": []} + + def test_drain_replays_schemas_but_not_rows(self): + buffer = OpBuffer() + buffer.new_table("t", {"a": "integer"}) + buffer.update("t", [{"a": 1}]) + buffer.drain() + + batch = buffer.drain() + assert batch["schemas"] == {"t": {"a": "integer"}} + assert batch["updates"] == {} + + def test_update_accepts_a_single_row(self): + buffer = OpBuffer() + buffer.update("t", {"a": 1}) + assert buffer.drain()["updates"] == {"t": [{"a": 1}]} + + def test_new_table_does_not_replace_an_existing_schema(self): + buffer = OpBuffer() + buffer.new_table("t", {"a": "integer"}) + buffer.new_table("t", {"b": "string"}) + assert buffer.drain()["schemas"] == {"t": {"a": "integer"}} + + def test_rows_are_capped_per_table(self): + buffer = OpBuffer(max_rows_per_table=3) + buffer.update("t", [{"a": i} for i in range(10)]) + assert buffer.drain()["updates"]["t"] == [{"a": 7}, {"a": 8}, {"a": 9}] + + def test_clear_drops_pending_rows(self): + buffer = OpBuffer() + buffer.update("t", [{"a": 1}]) + buffer.clear("t") + + batch = buffer.drain() + assert batch["updates"] == {} + assert batch["cleared"] == ["t"] + + @pytest.mark.parametrize("batch", [{}, {"updates": {"t": []}}, {"schemas": None, "cleared": None}]) + def test_extend_tolerates_sparse_batches(self, batch): + buffer = OpBuffer() + buffer.extend(batch) + assert buffer.drain() == {"schemas": {}, "updates": {}, "cleared": []} + + def test_extend_round_trips_a_drained_batch(self): + source = OpBuffer() + source.new_table("t", {"a": "integer"}) + source.update("t", [{"a": 1}]) + + target = OpBuffer() + target.extend(source.drain()) + assert target.drain() == {"schemas": {"t": {"a": "integer"}}, "updates": {"t": [{"a": 1}]}, "cleared": []} diff --git a/raydar/tests/test_serve.py b/raydar/tests/test_serve.py new file mode 100644 index 0000000..e6adad2 --- /dev/null +++ b/raydar/tests/test_serve.py @@ -0,0 +1,31 @@ +import httpx +import pytest +import ray + +from raydar.dashboard.serve import RaydarDeployment + +PORT = 8899 +BASE = f"http://127.0.0.1:{PORT}" + + +@pytest.fixture(scope="module") +def deployment(unittest_ray_cluster): + ray.serve.start(http_options={"host": "127.0.0.1", "port": PORT}) + handle = ray.serve.run(RaydarDeployment.bind(), name="raydar", route_prefix="/") + yield handle + ray.serve.shutdown() + + +class TestRaydarDeployment: + def test_the_app_is_built_on_the_replica(self, deployment): + # Regression: passing the app to `ingress` makes Ray pickle it, which fails + # on the Perspective server and the transports store it closes over. + assert httpx.get(BASE + "/").status_code == 200 + + def test_assets_are_served(self, deployment): + for asset in ("/tree.json", "/js/cdn/index.js", "/components/perspective/cdn/index.js"): + assert httpx.get(BASE + asset).status_code == 200, asset + + def test_apply_reaches_the_replica(self, deployment): + deployment.apply.remote({"schemas": {"t": {"a": "integer"}}, "updates": {"t": [{"a": 1}]}}).result() + assert httpx.get(BASE + "/").status_code == 200 diff --git a/raydar/tests/test_task_tracker.py b/raydar/tests/test_task_tracker.py index 655f5e9..9442494 100644 --- a/raydar/tests/test_task_tracker.py +++ b/raydar/tests/test_task_tracker.py @@ -1,10 +1,10 @@ import time +import httpx import pytest import ray -import requests -from raydar import RayTaskTracker, setup_proxy_server +from raydar import RayTaskTracker @ray.remote @@ -16,26 +16,46 @@ def do_some_work(): @pytest.mark.usefixtures("unittest_ray_cluster") class TestRayTaskTracker: def test_construction_and_dataframe(self): - task_tracker = RayTaskTracker(enable_perspective_dashboard=True) - assert len(task_tracker.namespace.split("-")) == 2 - refs = [do_some_work.remote() for _ in range(10)] - task_tracker.process(refs) - time.sleep(30) - df = task_tracker.get_df() - assert df[["name", "state"]].row(0) == ("do_some_work", "FINISHED") - - def test_get_proxy_server(self): - from raydar.dashboard.server import PerspectiveRayServer - - kwargs = { - "target": PerspectiveRayServer.bind(), - "name": "webserver", - "route_prefix": "/", - } - server = setup_proxy_server(**kwargs) - server.remote("new", "test_table", {"a": "string", "b": "integer", "c": "float", "d": "datetime"}) - time.sleep(2) - server.remote("update", "test_table", [{"a": "foo", "b": 1, "c": 1.0, "d": time.time()}]) - time.sleep(2) - response = requests.get("http://localhost:8000/tables") - assert eval(response.text) == ["test_table"] + task_tracker = RayTaskTracker(dashboard="local") + try: + assert len(task_tracker.namespace.split("-")) == 2 + refs = [do_some_work.remote() for _ in range(10)] + task_tracker.process(refs) + time.sleep(30) + df = task_tracker.get_df() + assert df[["name", "state"]].row(0) == ("do_some_work", "FINISHED") + finally: + task_tracker.dashboard.stop() + + def test_dashboard_is_off_by_default(self): + task_tracker = RayTaskTracker() + assert task_tracker.dashboard_url is None + + def test_dashboard_options_reach_the_dashboard(self): + layout = {"sizes": [1], "viewers": {}} + task_tracker = RayTaskTracker(dashboard="local", dashboard_options={"title": "custom", "layout": layout}) + try: + dashboard = task_tracker.dashboard.dashboard + dashboard.apply({"schemas": {"t": {"a": "integer"}}}) + assert dashboard.state.layout == layout + assert "custom" in httpx.get(task_tracker.dashboard_url).text + finally: + task_tracker.dashboard.stop() + + def test_local_dashboard_serves_tables_pulled_from_the_actor(self): + task_tracker = RayTaskTracker(dashboard="local") + try: + assert task_tracker.dashboard_url.startswith("http://127.0.0.1:") + task_tracker.create_table("custom", {"a": "string", "b": "integer"}) + task_tracker.update_table("custom", [{"a": "foo", "b": 1}]) + + tables = task_tracker.dashboard.dashboard.tables + expected = ["custom", "task_tracker_data"] + deadline = time.time() + 30 + while time.time() < deadline and sorted(tables.names()) != expected: + time.sleep(0.5) + + assert sorted(tables.names()) == expected + assert httpx.get(task_tracker.dashboard_url).status_code == 200 + finally: + task_tracker.dashboard.stop() From 693c51f3c5218879e345e96e93cab2d3a98c0169 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:56:01 -0400 Subject: [PATCH 2/5] Declare jinja2 and pandas as runtime dependencies CI failed importing raydar: ray.serve's haproxy module does `from jinja2 import Environment` at import time, but ray[serve] does not declare jinja2. raydar imports ray.serve at module level, so it has to declare jinja2 itself. Dropping jinja2 along with the Jinja2 templating went unnoticed locally because the old dependency was still installed. pandas has the same shape and was already wrong before this branch: it is imported at the top of task_tracker.py but was listed only under the develop extra, so `pip install raydar; import raydar` failed. Verified by installing the wheel into a fresh environment with only the declared dependencies. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 10e3929..34ee198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,9 @@ classifiers = [ dependencies = [ "coolname", + # ray.serve imports jinja2 at import time but does not declare it + "jinja2", + "pandas", "perspective-python>=4.5,<4.6", "polars", "pyarrow", @@ -71,8 +74,6 @@ develop = [ "ty", "uv", "wheel", - # Test deps - "pandas", ] [project.scripts] From f9c00b6dda5a8dd92237f0d313841283ff25aa81 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:52:02 -0400 Subject: [PATCH 3/5] Fix defects found in adversarial review A poll-loop failure was the worst of these: `apply` was called outside the try block, so one bad row killed the task and froze the dashboard with no error surfaced and no way to recover short of a restart. Move it inside the guard and cover it with a regression test. Stop churning the synced model. `drain` replays schemas on every call and returns a truthy dict even when nothing happened, so every poll rewrote the timestamp and broadcast a patch to every browser twice a second. Apply now tracks whether anything actually changed. Report rows from Perspective rather than a lifetime counter. The counter ignored both `limit` and `clear`, so a capped table showed 5 when it held 2, and a cleared one still showed 5 when it held 0. Scope teardown to raydar's own Serve application. `exit()` called the global `ray.serve.shutdown()` even for local and disabled dashboards, which would take unrelated deployments on the cluster down with it. Make the local dashboard's lifecycle safe: `stop` is idempotent and releases the socket, an unstarted dashboard no longer leaks its bound port, and a thread that outlives its join is no longer forgotten, which previously let a second server bind the same socket. Warn instead of failing silently in two places: a non-loopback bind now says it serves task metadata unauthenticated, and an existing actor whose dashboard mode differs from the requested one now says so rather than presenting a permanently empty dashboard. Also dedupe pending clears, which accumulated unboundedly between drains, and assert real table contents in the tests that previously only checked table names. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- raydar/dashboard/dashboard.py | 27 ++++++++++---- raydar/dashboard/local.py | 26 +++++++++---- raydar/ops.py | 3 +- raydar/task_tracker/task_tracker.py | 19 +++++++++- raydar/tests/test_dashboard.py | 58 +++++++++++++++++++++++++++-- 5 files changed, 111 insertions(+), 22 deletions(-) diff --git a/raydar/dashboard/dashboard.py b/raydar/dashboard/dashboard.py index ee51223..6d47bb2 100644 --- a/raydar/dashboard/dashboard.py +++ b/raydar/dashboard/dashboard.py @@ -39,19 +39,24 @@ def __init__(self, limit: int | None = None): self._limit = limit self._schemas: dict[str, dict] = {} self._tables: dict[str, Any] = {} - self.total_rows = 0 def names(self) -> list[str]: return list(self._schemas) - def new_table(self, tablename: str, schema: dict) -> None: + def total_rows(self) -> int: + """Rows currently held, which `limit` and `clear` both reduce.""" + return sum(table.size() for table in self._tables.values()) + + def new_table(self, tablename: str, schema: dict) -> bool: + """Create a table, returning whether it did not already exist.""" if tablename in self._schemas: - return + return False self._schemas[tablename] = schema kwargs = {"name": tablename} if self._limit is not None: kwargs["limit"] = self._limit self._tables[tablename] = self._client.table(schema, **kwargs) + return True def update(self, tablename: str, data) -> None: if isinstance(data, dict): @@ -59,7 +64,6 @@ def update(self, tablename: str, data) -> None: if tablename not in self._tables: raise KeyError(f"No such table: {tablename}") self._tables[tablename].update(data) - self.total_rows += len(data) def clear(self, tablename: str) -> None: if tablename in self._tables: @@ -130,19 +134,26 @@ async def _perspective_socket(self, websocket: WebSocket) -> None: def apply(self, batch: dict) -> None: """Apply a drained :class:`~raydar.ops.OpBuffer` batch to the tables.""" + changed = False for tablename, schema in (batch.get("schemas") or {}).items(): - self.tables.new_table(tablename, schema) + changed |= self.tables.new_table(tablename, schema) for tablename in batch.get("cleared") or (): self.tables.clear(tablename) + changed = True for tablename, rows in (batch.get("updates") or {}).items(): - self.tables.update(tablename, rows) - self._refresh_state() + if rows: + self.tables.update(tablename, rows) + changed = True + # Schemas are replayed on every drain, so most batches are empty; only + # touch the synced model when something actually moved. + if changed: + self._refresh_state() def _refresh_state(self) -> None: names = self.tables.names() if names != self.state.tables: self.state.tables = names self.state.layout = self._layout_override or default_layout(names) - self.state.rows = f"{self.tables.total_rows:,}" + self.state.rows = f"{self.tables.total_rows():,}" self.state.status = "Live" if names else "Waiting for data" self.state.updated = datetime.now(tz=UTC).astimezone().strftime("%H:%M:%S") diff --git a/raydar/dashboard/local.py b/raydar/dashboard/local.py index 517c2e7..b046938 100644 --- a/raydar/dashboard/local.py +++ b/raydar/dashboard/local.py @@ -47,6 +47,9 @@ def __init__( self._poll_interval = poll_interval self._thread: threading.Thread | None = None + if host not in ("127.0.0.1", "localhost", "::1"): + logger.warning(f"raydar dashboard is bound to {host} and serves Ray task metadata without authentication") + # Bind up front so `url` is accurate before the server thread starts. self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -66,18 +69,19 @@ def url(self) -> str: async def _poll(self) -> None: while True: + # Applying is inside the guard too: one bad row must not kill the loop + # and leave the dashboard silently frozen. try: batch = await asyncio.to_thread(self._drain) + if batch: + self.dashboard.apply(batch) except Exception: - logger.exception("Failed to fetch dashboard updates") - batch = None - if batch: - self.dashboard.apply(batch) + logger.exception("Failed to apply dashboard updates") await asyncio.sleep(self._poll_interval) def start(self) -> str: """Start serving in a daemon thread and return the dashboard URL.""" - if self._thread is not None: + if self._thread is not None and self._thread.is_alive(): return self.url self._thread = threading.Thread( target=lambda: asyncio.run(self._server.serve(sockets=[self._socket])), @@ -89,8 +93,14 @@ def start(self) -> str: return self.url def stop(self) -> None: - """Ask the server to exit and wait for the thread to finish.""" + """Ask the server to exit and wait for the thread to finish. Idempotent.""" self._server.should_exit = True - if self._thread is not None: - self._thread.join(timeout=5) + thread = self._thread + if thread is not None: + thread.join(timeout=5) + if thread.is_alive(): + # Keep the reference so `start` cannot bind a second server to this socket. + logger.warning("raydar dashboard thread did not stop within 5s") + return self._thread = None + self._socket.close() diff --git a/raydar/ops.py b/raydar/ops.py index 1a65dad..b100ab7 100644 --- a/raydar/ops.py +++ b/raydar/ops.py @@ -40,7 +40,8 @@ def update(self, tablename: str, data) -> None: def clear(self, tablename: str) -> None: self._updates.pop(tablename, None) - self._cleared.append(tablename) + if tablename not in self._cleared: + self._cleared.append(tablename) def extend(self, batch: dict) -> None: """Absorb a batch in the same shape :meth:`drain` produces.""" diff --git a/raydar/task_tracker/task_tracker.py b/raydar/task_tracker/task_tracker.py index f426ebb..bcd9eb4 100644 --- a/raydar/task_tracker/task_tracker.py +++ b/raydar/task_tracker/task_tracker.py @@ -8,7 +8,7 @@ import pandas as pd import polars as pl import ray -from ray.serve import shutdown +from ray.serve import delete as delete_serve_app from ..ops import OpBuffer from .schema import schema as default_schema @@ -162,6 +162,9 @@ def drain(self) -> dict | None: return None return self._buffer.drain() + def get_dashboard_mode(self) -> str | None: + return self.dashboard_mode + def callback(self, tasks: Iterable[ray.ObjectRef]) -> None: """A remote function used by this actor's processor actor attribute. Will be called by a separate actor with a collection of ray object references once those ObjectReferences are not in the "RUNNING" or @@ -357,6 +360,15 @@ def __init__( if dashboard == "local": from raydar.dashboard import LocalDashboard + # `get_if_exists` returns a pre-existing actor and drops these constructor + # args, so a mode mismatch would otherwise show as an empty dashboard. + active = ray.get(self.tracker.get_dashboard_mode.remote()) + if active != dashboard: + logger.warning( + f'Actor "{name}" in namespace "{namespace}" already exists with dashboard={active!r}, ' + f"so it will not feed a {dashboard!r} dashboard. Use a new name or namespace." + ) + self.dashboard = LocalDashboard( drain=lambda: ray.get(self.tracker.drain.remote()), host=dashboard_host, @@ -407,4 +419,7 @@ def exit(self) -> None: self.dashboard = None ray.kill(ray.get_actor(name=self.name, namespace=self.namespace)) ray.kill(ray.get_actor(name=get_callback_actor_name(self.name), namespace=self.namespace)) - shutdown() + if self.dashboard_mode == "cluster": + # Delete only our own application; a global serve shutdown would take + # unrelated deployments with it. + delete_serve_app("raydar") diff --git a/raydar/tests/test_dashboard.py b/raydar/tests/test_dashboard.py index a10bdcf..80bd325 100644 --- a/raydar/tests/test_dashboard.py +++ b/raydar/tests/test_dashboard.py @@ -1,9 +1,11 @@ import json +import socket +import time import pytest from starlette.testclient import TestClient -from raydar.dashboard import Dashboard, default_layout +from raydar.dashboard import Dashboard, LocalDashboard, default_layout SCHEMA = {"a": "integer", "b": "string"} @@ -56,16 +58,29 @@ def test_repeated_schemas_do_not_recreate_tables(self, dashboard): def test_clear_empties_the_table(self, dashboard): dashboard.apply({"schemas": {"t": SCHEMA}, "updates": {"t": [{"a": 1, "b": "x"}]}}) dashboard.apply({"cleared": ["t"]}) + assert dashboard.tables.names() == ["t"] + assert dashboard.tables.total_rows() == 0 + assert dashboard.state.rows == "0" def test_update_of_an_unknown_table_raises(self, dashboard): with pytest.raises(KeyError): dashboard.apply({"updates": {"nope": [{"a": 1}]}}) - def test_limit_is_applied_to_new_tables(self): + def test_limit_caps_retained_rows(self): dashboard = Dashboard(limit=2) dashboard.apply({"schemas": {"t": SCHEMA}, "updates": {"t": [{"a": i, "b": "x"} for i in range(5)]}}) - assert dashboard.tables.names() == ["t"] + + assert dashboard.tables.total_rows() == 2 + assert dashboard.state.rows == "2" + + def test_an_empty_batch_does_not_touch_the_synced_state(self, dashboard): + dashboard.apply({"schemas": {"t": SCHEMA}}) + before = dashboard.state.model_copy(deep=True) + + # Schemas are replayed on every drain, so this is the steady-state batch. + dashboard.apply({"schemas": {"t": SCHEMA}, "updates": {}, "cleared": []}) + assert dashboard.state == before class TestDashboardApp: @@ -94,3 +109,40 @@ def test_state_is_pushed_over_the_transports_socket(self, dashboard): def test_perspective_has_its_own_socket(self, dashboard): with TestClient(dashboard.app) as client, client.websocket_connect("/perspective") as ws: assert ws is not None + + +class TestLocalDashboard: + def test_poll_loop_survives_a_bad_batch(self): + batches = [ + {"updates": {"missing": [{"a": 1}]}}, # unknown table -> KeyError + {"schemas": {"t": SCHEMA}, "updates": {"t": [{"a": 1, "b": "x"}]}}, + ] + local = LocalDashboard(drain=lambda: batches.pop(0) if batches else {}, poll_interval=0.05) + local.start() + try: + deadline = time.time() + 10 + while time.time() < deadline and local.dashboard.tables.names() != ["t"]: + time.sleep(0.1) + # The good batch only lands if the failed one did not kill the loop. + assert local.dashboard.tables.names() == ["t"] + assert local.dashboard.tables.total_rows() == 1 + finally: + local.stop() + + def test_stop_is_idempotent_and_releases_the_port(self): + local = LocalDashboard(drain=lambda: None, poll_interval=0.05) + port = local.port + local.start() + local.stop() + local.stop() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", port)) + + def test_an_unstarted_dashboard_releases_its_socket(self): + local = LocalDashboard(drain=lambda: None) + port = local.port + local.stop() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", port)) From 1ca146e9a9cb97b60b716177d0bc5a51309800d7 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:20:54 -0400 Subject: [PATCH 4/5] Send datetimes to Perspective as millis and neutralise the theme Perspective 4 encodes rows as JSON, which has no datetime, so passing a `datetime` raised `TypeError: Object of type datetime is not JSON serializable`. Perspective 3 accepted them, so this broke the pattern the README documents and any user code carrying it over. Coerce `datetime` and `date` to epoch millis for columns declared `datetime` or `date`, keeping the documented API working. Epoch *seconds* were the more dangerous case: Perspective accepts a float and reads it as millis, so `time.time()` silently rendered every row as 1970. The demo and the metrics example in the wiki both did this; they now pass datetimes. Coercion deliberately does not guess a bare number's unit. The existing tests only used integer and string columns, which is why none of this showed up. Cover the datetime path. Separately, the chrome was tinted: #0f172a against Perspective's dark viewer, which is a neutral #242526, read as purple. Match Perspective's own greys in both themes so the header and the grid look like one application. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- docs/wiki/Key-Features.md | 2 +- raydar/dashboard/dashboard.py | 18 +++++++++++++++++- raydar/dashboard/demo.py | 7 ++++--- raydar/dashboard/page.py | 13 ++++++++----- raydar/tests/test_dashboard.py | 15 +++++++++++++++ 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/wiki/Key-Features.md b/docs/wiki/Key-Features.md index 62b2aeb..7a45f66 100644 --- a/docs/wiki/Key-Features.md +++ b/docs/wiki/Key-Features.md @@ -138,7 +138,7 @@ def my_model_training_loop(): node_id=ray.get_runtime_context().get_node_id(), metric_name="loss", value=loss.item(), - timestamp=time.time(), + timestamp=datetime.datetime.now(), ) task_tracker.update_table("metrics_table", [data]) ``` diff --git a/raydar/dashboard/dashboard.py b/raydar/dashboard/dashboard.py index 6d47bb2..3500208 100644 --- a/raydar/dashboard/dashboard.py +++ b/raydar/dashboard/dashboard.py @@ -10,7 +10,7 @@ import asyncio from collections.abc import Awaitable, Callable, Sequence from contextlib import asynccontextmanager -from datetime import UTC, datetime +from datetime import UTC, date, datetime, time from typing import Any import perspective @@ -29,6 +29,17 @@ __all__ = ("Dashboard", "TableHost") +_TEMPORAL_TYPES = ("datetime", "date") + + +def _to_epoch_millis(value): + """Perspective encodes rows as JSON, which has no datetime, so send millis.""" + if isinstance(value, datetime): + return int(value.timestamp() * 1000) + if isinstance(value, date): + return int(datetime.combine(value, time()).timestamp() * 1000) + return value + class TableHost: """Owns a Perspective server and the tables served over its websocket.""" @@ -39,6 +50,7 @@ def __init__(self, limit: int | None = None): self._limit = limit self._schemas: dict[str, dict] = {} self._tables: dict[str, Any] = {} + self._temporal_columns: dict[str, set[str]] = {} def names(self) -> list[str]: return list(self._schemas) @@ -52,6 +64,7 @@ def new_table(self, tablename: str, schema: dict) -> bool: if tablename in self._schemas: return False self._schemas[tablename] = schema + self._temporal_columns[tablename] = {column for column, kind in schema.items() if kind in _TEMPORAL_TYPES} kwargs = {"name": tablename} if self._limit is not None: kwargs["limit"] = self._limit @@ -63,6 +76,9 @@ def update(self, tablename: str, data) -> None: data = [data] if tablename not in self._tables: raise KeyError(f"No such table: {tablename}") + temporal = self._temporal_columns[tablename] + if temporal: + data = [{key: _to_epoch_millis(value) if key in temporal else value for key, value in row.items()} for row in data] self._tables[tablename].update(data) def clear(self, tablename: str) -> None: diff --git a/raydar/dashboard/demo.py b/raydar/dashboard/demo.py index d94e567..0e65c46 100644 --- a/raydar/dashboard/demo.py +++ b/raydar/dashboard/demo.py @@ -6,6 +6,7 @@ import random import time +from datetime import UTC, datetime import ray @@ -23,13 +24,13 @@ @ray.remote def demo_job(backoff: float) -> dict: - start = time.time() + start = datetime.now(tz=UTC) time.sleep(backoff) - end = time.time() + end = datetime.now(tz=UTC) return { "start": start, "end": end, - "runtime": end - start, + "runtime": (end - start).total_seconds(), "backoff": backoff, "random": random.random(), } diff --git a/raydar/dashboard/page.py b/raydar/dashboard/page.py index 35ed09e..1d96b52 100644 --- a/raydar/dashboard/page.py +++ b/raydar/dashboard/page.py @@ -10,14 +10,17 @@