diff --git a/.gitignore b/.gitignore index 634ecc25..5f5db373 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,20 @@ charts/taugrid/charts # Portal frontend dependencies and local build metadata portal/frontend/node_modules/ portal/frontend/*.tsbuildinfo +# Agent scratch: briefs, reports, throwaway environments. +.codex-review/ +sdk/python/python/share/ +sdk/python/python/build/ +sdk/python/python/tau.egg-info/ + +# Local Jupyter extension build output and e2e artifacts +sdk/python/python/labextension/node_modules/ +sdk/python/python/labextension/lib/ +sdk/python/python/labextension/tsconfig.tsbuildinfo +jupyter-*-e2e*.png +tools/jupyter-*.png +# Python bytecode +__pycache__/ +*.pyc +# Jupyter checkpoints +.ipynb_checkpoints/ diff --git a/docs/design/assets/notebook-logs.png b/docs/design/assets/notebook-logs.png new file mode 100644 index 00000000..9a4515c7 Binary files /dev/null and b/docs/design/assets/notebook-logs.png differ diff --git a/docs/design/assets/notebook-run-detail-loss.png b/docs/design/assets/notebook-run-detail-loss.png new file mode 100644 index 00000000..1d37702c Binary files /dev/null and b/docs/design/assets/notebook-run-detail-loss.png differ diff --git a/docs/design/assets/notebook-runs-sidebar.png b/docs/design/assets/notebook-runs-sidebar.png new file mode 100644 index 00000000..ae42386f Binary files /dev/null and b/docs/design/assets/notebook-runs-sidebar.png differ diff --git a/docs/design/assets/notebook-submit-confirm.png b/docs/design/assets/notebook-submit-confirm.png new file mode 100644 index 00000000..80d5cfe7 Binary files /dev/null and b/docs/design/assets/notebook-submit-confirm.png differ diff --git a/docs/design/assets/notebook-submit-review.png b/docs/design/assets/notebook-submit-review.png new file mode 100644 index 00000000..199813c6 Binary files /dev/null and b/docs/design/assets/notebook-submit-review.png differ diff --git a/docs/design/notebook-plugin.md b/docs/design/notebook-plugin.md index 1548218f..eac116a2 100644 --- a/docs/design/notebook-plugin.md +++ b/docs/design/notebook-plugin.md @@ -1,461 +1,417 @@ -# TauGrid Notebook Plugin - Design - -Status: **proposal, awaiting review.** No implementation code is written yet. - -Scope: a notebook surface for TauGrid that lets a researcher (a) submit the notebook they are working in as a Tau job and (b) watch one integrated dashboard for that run: Ray dashboard, GPU usage, and the training loss curve. - -Primary target editor: **Jupyter Notebook**. -Secondary compatibility targets (best-effort, not promised supported surfaces): JupyterLab, VS Code notebooks, Google Colab. - -This is a **notebook plugin**: an **ipywidgets component** rendered inside notebook outputs. It is not a Codex/DeepSeek/Claude plugin, and it does not add a second web UI. - -## Decisions already made (do not re-litigate) - -| # | Decision | Consequence | -|---|---|---| -| D1 | **No CLI dependency.** No `tau` binary, no `kubectl` at runtime. | Submit is pure Python against Kubernetes APIs. | -| D2 | **The end user imports nothing.** The deliverable is a button/component. | The one `import` lives in a platform-authored template cell, not in the user's code. | -| D3 | **The job is the current notebook.** | Submit packages the `.ipynb` and runs it on the cluster. No `@tau.train` decorator in the user's notebook. | -| D4 | **ipywidgets, not a JupyterLab prebuilt extension.** | Portable across notebook surfaces with caveats ([ipywidgets Installation](https://ipywidgets.readthedocs.io/en/stable/user_install.html), [VS Code Jupyter notebooks](https://code.visualstudio.com/docs/datascience/jupyter-notebooks), [Colab widgets notebook](https://colab.research.google.com/notebooks/widgets.ipynb)). | -| D5 | **Kubernetes Python SDK** is the cluster client. | Submit/read flows use `CoreV1Api` and `CustomObjectsApi` ([Kubernetes Python Client](https://github.com/kubernetes-client/python), [CustomObjectsApi docs](https://github.com/kubernetes-client/python/blob/master/kubernetes/docs/CustomObjectsApi.md)). | - ---- - -## 1. Context loop trace - -What was mined before writing this, and what it settled. - -> Reviewer note: this section was audited to remove chat/session metadata. -> Any repository claim without grep-backed `file:line` confirmation is explicitly marked **ASSUMPTION (UNVERIFIED)**. - -| # | Source | Signal | Verification status | -|---|---|---|---| -| 1 | `AGENTS.md`, repo tree | Repo appears multi-module (`cli/`, `core/`, `controllers/`, `portal/`, `sdk/python/`). | **ASSUMPTION (UNVERIFIED)** - add exact `file:line` citations in follow-up pass. | -| 2 | `cli/go.mod` | Prior note claims Kubernetes Go clients are direct deps; Azure SDK usage is scoped to infra/telemetry helpers. | **ASSUMPTION (UNVERIFIED)**. | -| 3 | `core/kube/kubectl.go` | Prior note claims normal manifest/query path shells out to `kubectl`, with narrow direct REST usage. | **ASSUMPTION (UNVERIFIED)**. | -| 4 | `cli/internal/runhistory/kubernetes.go`, `cli/internal/cli/run_submit.go` | Prior note claims typed `kubernetes.Interface` + `dynamic.Interface` are used in submit/history paths. | **ASSUMPTION (UNVERIFIED)**. | -| 5 | `controllers/tau-core/api/v1alpha1/types.go` | Prior note claims Tau CRDs include `TauCluster`, `TauWorkspace`, `TauQuotaRequest`; profile/queue defaults come from `TauCluster.spec`. | **ASSUMPTION (UNVERIFIED)**. | -| 6 | `cli/internal/rayjobrender/render.go` | Prior note claims renderer handles Kueue labels/suspend, topology, GPU claims, TTL, entrypoint staging. | **ASSUMPTION (UNVERIFIED)**. | -| 7 | `core/status/gpu.go` | Prior note claims GPU metric fields include explicit observed booleans to distinguish zero vs missing. | **ASSUMPTION (UNVERIFIED)**. | -| 8 | `portal/internal/expapi/server.go` | Prior note claims `/api/stellar/series` shape used for loss charting. | **ASSUMPTION (UNVERIFIED)**. | -| 9 | `portal/internal/portalapi/rayproxy.go`, `server.go` | Prior note claims Ray dashboard proxy route and `X-Frame-Options: SAMEORIGIN` behavior. | **ASSUMPTION (UNVERIFIED)**. | -| 10 | `sdk/python/python/tau/workloads.py`, `sdk/python/python/tau/_cluster.py` | Prior note claims current Python submit handle shells through CLI and ships cluster-side wrapper code. | **ASSUMPTION (UNVERIFIED)**. | -| 11 | KubeRay upstream docs | KubeRay includes an optional APIServer; V1/V2 lifecycle and compatibility are documented upstream. | **Externally cited** ([KubeRay APIServer README](https://github.com/ray-project/kuberay/blob/master/apiserver/README.md), [KubeRay repo `apiserver/`](https://github.com/ray-project/kuberay/tree/master/apiserver)). | - -### Corrections applied in this revision - -- Removed non-repository/session-specific claims (for example, “which skills are installed in this chat session”). -- Removed author-internal draft-history language. -- Marked all unverified repo-path assertions as **ASSUMPTION (UNVERIFIED)** pending grep-backed `file:line` citations. -- Added external citations for Kubernetes SDK, KubeRay APIServer docs, ipywidgets portability, notebook execution tooling, and browser framing behavior. - ---- - -## 2. The question this work answers - -> Can a researcher who is already working in a notebook get that notebook onto a GPU cluster, and then answer “is my loss going down, are my GPUs working, what is Ray doing” by clicking, without writing YAML, without opening a terminal, and without importing anything? - -Today that answer typically spans multiple contexts (terminal, portal, notebook output). The plugin collapses that into one panel. - ---- - -## 3. Personas - -| Persona | Goal in this surface | What they will not do | -|---|---|---| -| **Researcher (primary)** - owns a training notebook | Click Submit, then watch loss + GPU until done or broken | Read YAML, open a terminal, learn Kueue, write imports | -| **ML platform engineer (secondary)** - supports researchers | Provide notebook template + RBAC; reproduce a run from a shared notebook | Rebuild a dashboard per incident | -| **Reviewer / collaborator (tertiary)** - reads a shared notebook | See evidence that the run trained and converged | Run anything | - -**Who authors the import?** The platform engineer, once, in the notebook template. The researcher never writes it. This is how D2 and D4 coexist: ipywidgets needs code somewhere, but not user-authored code. - ---- - -## 4. Backbone (user activities) - -Frame: activity flow. Perspective: primary user. Horizon: single run session. -Granularity: 5 activities. Scope: happy path + failure recovery. Aggregation: single role. - -1. **Open a ready notebook** - start from the platform template, which already includes the TauGrid panel cell. -2. **Submit the notebook** - click Submit; plugin packages notebook and applies workload. -3. **Watch it converge** - loss curve, GPU utilization, step progress. -4. **Inspect cluster state** - Ray dashboard link, pods, queue/admission context. -5. **Recover or hand off** - read failure signal, capture next command, export/share evidence. - -Cross-cutting backlog (not backbone): renderer conformance suite, packaging robustness, offline test strategy, docs. - ---- - -## 5. Architecture - -### 5.1 Cluster integration target and client choice - -D1 and D5 drive one runtime shape: direct Kubernetes API access from Python. - -- Use `kubernetes` Python client for cluster I/O ([Kubernetes Python Client](https://github.com/kubernetes-client/python)). -- Use `CoreV1Api` for pods/logs/nodes. -- Use `CustomObjectsApi` for Tau/Kueue/Ray CRDs ([CustomObjectsApi docs](https://github.com/kubernetes-client/python/blob/master/kubernetes/docs/CustomObjectsApi.md)). - -Repository-specific details of current CLI internals are still **ASSUMPTION (UNVERIFIED)** in this draft (see §1). The design remains valid because the widget path is explicitly independent of CLI runtime calls. - -### 5.1.1 KubeRay APIServer note (correction) - -KubeRay does ship an optional APIServer component ([KubeRay APIServer README](https://github.com/ray-project/kuberay/blob/master/apiserver/README.md)). - -| Topic | External evidence | Design implication | -|---|---|---| -| APIServer exists | Upstream `apiserver/` docs are present. | Plugin must not assume APIServer absence. | -| V1/V2 lifecycle | Upstream docs discuss V1/V2; exact deprecation wording should be rechecked before implementation freeze. | Do not build a V1-specific client path in Slice 1. | -| Client shape | Upstream docs describe Kubernetes-oriented API compatibility/proxy behavior for V2 (exact wording to confirm in implementation PR). | Keep API base URL configurable; default direct Kubernetes API server path. | - -TauGrid-repo-specific deployment claims (“TauGrid does/doesn’t deploy KubeRay APIServer”) remain **ASSUMPTION (UNVERIFIED)** until repo grep provides exact `file:line` evidence. - -### 5.2 Submit without CLI, without decorator - -D1 + D3 means the plugin must transform a **notebook** into a runnable workload. - -``` -analysis.ipynb - │ 1) resolve notebook path (Jupyter Sessions API where available) - │ 2) fetch notebook bytes (Jupyter Contents API where available) - ▼ -package ──► staged payload (notebook + thin runner) - │ - │ 3) resolve profile + queue defaults - │ 4) render ray.io/v1 RayJob (Python renderer port) - ▼ -apply via CustomObjectsApi ──► admission ──► notebook executes on cluster -``` - -Notebook/session path resolution references: -- [Jupyter Server REST API: Sessions](https://jupyter-server.readthedocs.io/en/latest/developers/rest-api.html#api-sessions) -- [Jupyter Server REST API: Contents](https://jupyter-server.readthedocs.io/en/latest/developers/rest-api.html#api-contents) - -Runner choices: -- `nbconvert --execute` path ([nbconvert Execute API](https://nbconvert.readthedocs.io/en/latest/execute_api.html)) -- optional `papermill` path if image has it ([Papermill Documentation](https://papermill.readthedocs.io/en/latest/)) - -This introduces a runtime-image requirement (see §9). - -**Renderer parity requirements (ASSUMPTION from prior repo notes; verify against `cli/internal/rayjobrender/render.go` with file:line):** - -| Renderer concern | Why it cannot be skipped | +# TauGrid notebook plugin: architecture and whole-PR audit + +## Whole-PR audit — 2026-09-23 + +Baseline: local `origin/main...eb9221b`, including every changed path. No remote +PR operations, Jupyter startup, browser automation, or cluster writes. Existing +screenshot deletions and their doc note are preserved. Evidence below uses +**pre-reimplementation** lines; Python/frontend paths are relative to +`sdk/python/python/`. + +### Findings and reimplementation decisions + +1. **Review is not bound to the resolved plan.** `tau/jupyter/server.py:234` + rebuilds against current profiles/environment; `labextension/src/submission.ts:62` + pins only name/namespace. Bind native writes to a rendered-plan digest and + reject changes before creation. Validate request and response shapes. +2. **Reviews leak notebook contents into temporary directories.** + `tau/jupyter/submit.py:164` uses `mkdtemp` without cleanup although only embedded + bytes are needed. Package in memory by default, retaining explicit staging. + `tau/widgets/panel.py:237` duplicates package/resolve/render/create logic. + Share that pipeline, keeping legacy HTTPMode and native K8sJobMode/600s. +3. **Cluster discovery is arbitrary and unbounded.** `submit.py:98,108` lists + without limits/timeouts and selects `items[0]`. Reject ambiguity, bound reads. + `_profile.py:72` drops scheduling/memory and coerces zero workers to one. + Preserve supported fields and reject invalid shapes. Kubernetes create + conflicts, not read-then-create at `submit.py:224`, govern collisions. +4. **Native reads lack byte bounds.** `runs.py:85,114,152,179,212,270` preload + object/list responses; count limits are not byte limits. `server.py:185` runs + namespace discovery on the event loop. Centralize bounded transport, offload + Kubernetes calls, and report continuation even on short namespace pages. +5. **Logs race ownership discovery.** `runs.py:354-362` checks membership once, + then reads a named pod without rechecking its UID. Metrics check before + (`metrics.py:106`), not after. Revalidate workload/controller chain/pod/container + before and after bounded reads. This detects observed changes, not atomicity. +6. **Portal fallback bypasses failed/ambiguous discovery.** `metrics.py:202` + retries a prior portal source without its original eligibility conditions. + Apply the same conditions on refresh. Cache keys (`:188`) lack cluster identity; + partition by API host. Preserve TTL, single-flight, four-slot capacity, + terminal attempt, finite samples and strict source non-merging. +7. **Duplicated status mappings misclassify evidence.** `runs.py:134` searches + reason substrings (`Stopping` matches `stop`); `widgets/status.py:106` treats + unrecognized status as running (missing status is a queued compatibility case). Share explicit mappings, preserve old + RunStatus fields, and report unrecognized evidence as unknown. +8. **Frontend contracts/accessibility gaps.** `submission.ts:36,70` and + `widget.tsx:32` cast unchecked JSON. `widget.tsx:86` presents loading as empty. + A namespace label does not prove admission readiness. `view.tsx:81` expects + `warning`, but diagnostics use `warn`. Consolidate predicates, validate + payloads, distinguish loading/error/empty and correct warning tone. +9. **Dead CSS and mismatched states.** `style/index.css:96,128,239-290,528, + 589-608,713` retain unused readonly/guide/family/phase-detail/badge rules. + Actual states include success/done/skipped/unknown/warning. Align selectors + without redesigning verified table/tab/chart placement or Portal colors. +10. **Unwired Go TensorBoard feature.** `cli/internal/tensorboard` has no + production caller. `portal/internal/portalapi/server.go:359` exposes its proxy; + `tensorboardproxy.go:31` checks only nonempty strings; `:121` reads unlimited + HTML. Delete this feature/tests/route, leaving established Portal/Ray proxies. +11. **Legacy widgets are compatibility APIs, not the native plugin.** Keep + panel/embed/magic/session/render/watcher and tests. They use kernel identity, + imports/magic and no server release gate. `embed.py:105` incorrectly calls a + different-port loopback proxy same-origin. Correct and document trusted local + use. Escape unresolved panel text (`panel.py:414`). Remove unused + `_backend.py:67` CLI wrapper; actual SDK CLI execution remains unchanged. + `_conformance.py` is a scaffold, not demonstrated CLI parity. +12. **Tests/docs overstate coverage.** `test_jupyter_submit.py:112` verifies + mounts, not execution; submitters forward remote driver output. Frontend + static/source tests do not prove browser effects. Add negative and race + regressions. Separate CPU demo/screenshots/parent browser verification from + offline evidence and correct misleading runtime/legacy documentation. + +### Additional findings confirmed during reimplementation + +13. **Launcher recognition could delete real user code.** At the audited HEAD, + `tau/_notebook_pkg.py:40` and `:98-111` treated broad line-pattern matches as + approved launchers. Replace this with AST-based recognition of actual Tau + imports/aliases and literal launcher calls, preserving mixed and unrelated + code. Explicit launcher metadata and supported magics remain supported. +14. **Packaging could silently omit the native frontend.** At the audited HEAD, + `setup.py:35-37` conditionally included whatever assets happened to exist. + Fail packaging when the prebuilt manifest or its referenced entrypoint is + missing; rebuild from clean output and inspect both wheel install locations. + +### Whole-PR coverage and baseline + +- All native server modules; shared backend/payload/notebook/context/profile/ + renderer/conformance code; every legacy widget module and its offline tests. +- All eleven frontend source files; CSS/style entry; package/lockfile/tsconfig; + build helper, generated asset metadata and full npm suite. +- Setup/pyproject, extras/entry points/wheel layout, runtime Dockerfile, wheel + ignore rules, root ignores and license exclusions. +- All five TensorBoard Go files plus Portal route (the only Go additions here). +- Both root notebooks, all five SDK notebooks, CPU guide/runner, SDK inspection/ + live/cluster tools and all seven root browser helpers: read only, none run. +- Complete design history/site guide; five current PNGs match their site copies. + Historical captures and deleted superseded screenshots remain unchanged. +- Baseline: twelve pytest files **171 passed in 13.80s** from SDK directory; + `npm test` **31 passed, 0 failed**. Initial pytest from root failed collection + (`tests.test_widgets_submit` import); required SDK cwd resolves it. + +### Reimplementation validation + +Implementation and offline validation are recorded below. Installed JupyterLab/Notebook 7, +completed 40-point CPU run, actual admission/execution and browser confirmation +remain parent-owned live verification. + +## Current architecture + +This is the current implementation contract. It replaces overlapping historical +proposals; earlier live validation does not certify this refactor. + +### Native plugin and legacy compatibility + +The wheel contains a JupyterLab 4 prebuilt extension and Jupyter Server extension +configuration. Notebook 7 is a compatibility target; classic Notebook 6 is out of +scope. Native frontend calls only authenticated, base-URL-aware Jupyter endpoints. +Kubernetes and optional metrics-portal calls use the Jupyter Server identity. +There is no runtime tau/kubectl subprocess and no CLI binary requirement. +A researcher submits the open Python notebook without Tau imports or YAML. + +The tau.widgets panel, magic, embed and watcher remain supported **legacy SDK +APIs**, using kernel identity. They do not inherit the native server release gate. +The opt-in embed proxy is a separate loopback origin, removes upstream frame +restrictions and is suitable only for a trusted local kernel and portal. Remote +kernels require forwarding. It is not same-origin with Jupyter or a security +boundary. The native plugin never uses it. Existing CLI-executed SDK APIs remain +separate and unchanged. + +### One submission pipeline, two explicit policies + +The shared tau/_notebook_submit.py owns package → resolve → render → create. +Native policy is **K8sJobMode/600 seconds**; legacy stays **HTTPMode/15 seconds**. + +1. Review captures notebook bytes and inputs. The server validates field types, + Python notebook structure and the 10 MiB raw-input cap; the payload encoder + applies its smaller encoded transport budget. +2. Native packaging is memory-only. Explicit staging remains available to legacy + callers. Outputs/execution counts are stripped. Only explicit launcher metadata + or statically recognized Tau-only launcher statements are removed; mixed code + and unrelated calls remain intact. +3. Resolve the sole TauCluster with a bounded two-item discovery request; + ambiguity or continuation is a refusal. Preserve profile selectors/memory, + validate resource counts and resolve the selected profile/queue. +4. Render a suspended, Tau-managed, Kueue-labelled RayJob with a control-only head, + worker sizing, payload digest, runtime env and volume contract. Preserve the + verified notebook image and payload/mount wiring on head and submitter. + K8sJobMode submits to Ray and forwards remote driver output: this does **not** + mean the submitter itself executes the notebook driver. +5. Preview returns planDigest, SHA-256 of the complete canonical manifest. + Confirmation sends captured bytes, resolved name/namespace and this digest. + Rebuilding a different image/profile/queue/env/payload plan produces HTTP 409 + before any write. The digest checks consistency; it is not an authorization + credential or a Kubernetes transaction. +6. Native submit additionally requires TAUGRID_SUBMISSION_ENABLED=1 and + confirm=true. Create is the authoritative conflict check, with no pre-read. + Lost/failed responses are uncertain and never auto-retried. Inspect the planned + identity before reviewing again. Namespace discovery, preview and create use + worker threads rather than blocking Tornado's event loop. + +Admission and other resources may change after review. The digest cannot freeze +external cluster state. The unused CLI conformance scaffold was removed because +it did not compare equivalent inputs. Tests establish the supported manifest +contract, **not** full Go/Python runtime parity. + +### Lifecycle, logs and loss evidence + +- Native discovery caps lists at 500 objects per kind; failures/continuation remain + visible as partial evidence. Namespace labels are hints, not proof of a + LocalQueue or admission readiness. +- Admission, cluster creation, readiness, execution and results are separate + phases. Readiness is not training health. Explicit terminal failures dominate; + older job/deployment fields remain supported. New resources without status stay + queued for compatibility; unrecognized status is unknown, not invented running + progress. MultiKueue manager views do not fabricate remote-worker evidence. +- Shared non-preloaded Kubernetes transport caps documents at 4 MiB with a + seven-second per-document deadline and bounded connect/read timeouts. Native + clients disable retries. These limits do not promise one aggregate deadline + across a complete lifecycle query. +- Both logs and stdout metrics revalidate workload UID, controller ownership, + pod UID and container **before and after** a bounded log read. Discard the read + if a checked identity changes. The name-based log API is not atomic: this only + detects observed races, not every possible replacement/recreation interleaving. +- Logs select one pod/container, current or previous, 1–1000 tail lines, 65,536 + bytes plus sentinel. The 10-second read/verification budget excludes preceding + lifecycle discovery. No stream stays open; a tail is always partial. +- Loss accepts only explicit finite step=N loss=V observations, nonnegative safe + integer steps, records at most 4 KiB and at most 512 points in a 64 KiB tail. + Duplicate steps use the last observed value. No smoothing, invented step, + cross-source merging or multi-rank aggregation. A single point stays a point. +- Prefer the owned K8sJobMode submitter stream for RayJob; a batch Job needs one + unambiguous owned source. HTTPMode does not imply a readable remote driver log. + Failed/truncated/ambiguous discovery cannot enable portal fallback. +- Metrics cache partitions by API host, namespace/kind/name/UID and source. + It retains 10-second refresh, four concurrent reads, 128 entries, 600-second + idle eviction, single-flight and one terminal attempt per identity. Collection + errors keep previous timestamps and mark cached observations stale. +- Optional portal series require server enablement, URL and exact + namespace/kind/name/UID mapping to target/run_id. No browser-supplied inference, + redirects or credential forwarding. Refresh applies initial eligibility rules. + Operator-trusted mapping does not prove stdout equivalence or full coverage. + +### Native surfaces and contracts + +| Surface | Preserved contract | |---|---| -| `spec.suspend: true` and Kueue-compatible ownership | Prevents bypassing fair-share admission. | -| Queue labeling | Required for admission/routing. | -| Profile-derived sizing (workers, GPUs, selectors, priority class) | Keeps submitted shape consistent with platform contract. | -| Runtime environment propagation | Prevents missing dependencies on workers. | -| Staged entrypoint + payload wiring | Needed to execute notebook artifact in-cluster. | -| Head resource zeroing where applicable | Avoids wasting quota on control-only head. | -| GPU claim wiring (including modern claim styles) | Determines whether GPUs attach. | -| TTL/shutdown behavior | Prevents completed jobs from lingering. | - -**De-risking plan** - -1. **Backend seam** (`CliBackend` existing, `KubernetesBackend` new): runtime calls avoid CLI. -2. **Conformance suite**: render same logical workload in Go and Python, compare normalized RayJob objects in CI. -3. **Scope guardrails**: Slice 1 supports one notebook -> one run shape; unsupported shapes fail fast with explicit messages. - -### 5.3 Component architecture - -``` -┌────────────────── notebook frontend (Notebook primary; others best-effort) ──────────────────┐ -│ │ -│ ┌────────────────────────────── TauGrid panel (ipywidgets) ───────────────────────────────┐ │ -│ │ [Submit notebook] notebook: analysis.ipynb profile: ▾ queue: ▾ │ │ -│ │ ----------------------------------------------------------------------------------------- │ │ -│ │ loss summary + chart | GPU summary + bars | Ray dashboard link │ │ -│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │ -│ │ widget state (comm) │ -└──────────────────────────────────────┼──────────────────────────────────────────────────────────┘ - ▼ -┌──────────────────────────────────── kernel (Python) ───────────────────────────────────────────┐ -│ tau.widgets ── resolve notebook path / manual override │ -│ ── package .ipynb + runner │ -│ ── KubernetesBackend: resolve profile, render, apply │ -│ ── poll status + metrics; push updates to widget │ -└──────────────────────────────────────┬──────────────────────────────────────────────────────────┘ - │ kubernetes-python SDK - ▼ - ┌──────────────────────────────────────────┐ - │ Kubernetes API server + CRDs │ - │ tau.azure.com, kueue.x-k8s.io, ray.io/v1│ - └──────────────────┬───────────────────────┘ - │ - ▼ - ┌────────────────────────┐ ┌─────────────────────────┐ - │ taugrid-portal (HTTP) │ │ run metrics conventions │ - │ /api/stellar/series │ │ stdout / metrics file │ - │ /api/portal/ray/proxy │ │ │ - └────────────────────────┘ └─────────────────────────┘ -``` - -Runtime dependencies: `kubernetes`, `ipywidgets`, and SDK dependencies already in wheel (for example `PyYAML` if retained). - -### 5.4 Module layout - +| Sidebar | TauGrid: Open runs → left #jp-left-stack; data-testid=taugrid-runs; auto-loading .taugrid-runs-table; filtered input/datalist namespace picker, first Tau-labelled namespace preferred | +| Detail | data-testid=taugrid-detail; identity-scoped tab, lifecycle, admission, pods, diagnostics and loss | +| Loss | taugrid-loss-curve and taugrid-loss-samples; real points/sample table, source, freshness and partial-window notices | +| Logs | Separate data-testid=taugrid-logs tab; selection-specific bounded snapshots | +| Review | Notebook toolbar or TauGrid: Submit current notebook; data-testid=taugrid-review; Review submission → explicit named confirmation → Submit notebook | + +Namespace discovery distinguishes loading/unavailable/empty; queue changes clear +old rows. Runtime JSON validation rejects malformed capabilities, previews and +submission identities. Old RunStatus fields remain accepted; malformed optional +metrics do not destroy valid lifecycle evidence. Both warn and warning render +with warning tone. Removed obsolete CSS; actual success/done/warning states match. +The Portal's fixed light palette is intentional; Jupyter owns surrounding chrome. + +Watch is response-relative and single-flight; it starts for active runs, respects +manual pause and stops on terminal state, missing run, status error, disposal or +a one-hour watch deadline. Metrics-only failure does not stop lifecycle polling. + +### Deliberately removed or left alone + +Removed the uncalled CLI TensorBoard renderer and Portal TensorBoard proxy/route +with their isolated tests. Existing production Portal/Ray proxies remain. +Removed unused CliBackend and the misleading conformance scaffold; the actual +SDK CLI executor remains. Current screenshots/site copies are unchanged historic +evidence. The user's two pre-existing screenshot deletions are preserved. + +## Historical live evidence — not rerun + +The September 22, 2026 record reports CPU profile azure.research.cpu.small, +queue jobqueue, K8sJobMode and completed cpu-loss-demo-2. The parent-owned browser +run reported all five journeys working, including **40 real loss points and one +polyline**, sample disclosure, logs and actual submission. The earlier claim +that the submitter itself executed the notebook driver was too strong; preserve +its verified mounts without repeating that causal inference. + +Examples notebook-ray-cpu-demo.ipynb and notebook-loss-curve-demo.ipynb are +unchanged; the latter generates 40 deterministic CPU loss observations. +tools/run-cpu-ray-demo.py now forwards the preview digest and retains byte bounds, +UID/source checks, CPU-only verification, no retry of submission and no automatic +cancellation. tools/run-labextension-e2e.mjs is unchanged and was not run. + +## Validation and remaining verification + +The exact offline commands/results are recorded below. They do not prove browser layout, Notebook +7 activation, KubeRay admission/execution, RBAC or image availability. Parent-owned +live verification must repeat all five journeys, check 40 points/one polyline, +logs before/after completion and actual driver/submitter placement. Rebuild the +notebook runtime wheel/image before verifying changed executor behavior. +No Jupyter, Playwright, cluster writes, push or PR operations ran in this audit. + +Deferred: durable history/downloads, streaming logs, cancel/resume, remote-worker +hydration, inferred/authenticated portal mappings, multi-rank aggregation and +full Go/Python runtime conformance. + + +## Offline validation ledger — 2026-09-23 + +All commands below were local; no Jupyter server, Playwright, cluster test/write, +push, commit, or PR operation was run. Python commands use the existing SDK venv. +Go builds explicitly used `GOTOOLCHAIN=go1.26.7` and `GOFLAGS=-mod=readonly` +(the default installed Go is 1.27.0). The pinned toolchain was downloaded. + +### Required notebook and frontend gates + +From `sdk/python/python/labextension`, with the venv Scripts directory on PATH: + +```powershell +npm run build:lib +npm run build:labextension +npm test ``` -sdk/python/python/tau/ -├── _backend.py # CliBackend (existing) + KubernetesBackend (new) -├── _render.py # manifest -> ray.io/v1 RayJob / batch/v1 Job -├── _profile.py # profile + queue resolution from cluster state -├── _notebook_pkg.py # .ipynb -> staged payload (+ runner) -└── widgets/ - ├── __init__.py # public: panel(), TauGridPanel - ├── panel.py # ipywidgets layout, buttons, event wiring - ├── kube.py # kubeconfig/in-cluster config -> API clients - ├── status.py # RunStatus/GPUDevice dataclasses; read_run_status() - ├── metrics.py # MetricSeries, read_metrics(), loss selection - ├── render.py # HTML/SVG fragments for panes - └── session.py # notebook path resolution (+ manual override) -``` - -`_render.py`, `_profile.py`, `_notebook_pkg.py` stay outside `widgets/` because they are SDK capability, not only UI code. -### 5.5 How “no import for the end user” works +Before rebuilding, removed only `lib`, `tsconfig.tsbuildinfo`, and the generated +`../tau/labextension` directory, after checking their absolute paths. Both build +commands exited **0**; webpack compiled successfully. Final `npm test`: **33 +passed, 0 failed, 0 skipped**, 2518.9729 ms, exit **0**. A PostCSS parse also +confirmed no empty CSS rules. Rebuilt hashed assets are included in the changes. -ipywidgets still needs executable Python, so platform ships a template notebook with a pre-authored cell: +From `sdk/python/python`: -```python -# Cell 0 - platform-authored template cell -import tau.widgets as tg -tg.panel() +```powershell +.\.venv\Scripts\python.exe -m pytest tests/test_jupyter_submit.py tests/test_jupyter_runs.py tests/test_jupyter_metrics.py tests/test_notebook_loss_demo.py tests/test_widgets_core.py tests/test_widgets_submit.py tests/test_widgets_button.py tests/test_widgets_load.py tests/test_widgets_watcher.py tests/test_widgets_embed.py tests/test_widgets_plugin.py tests/test_widgets_surface.py tests/test_jupyter_contract.py tests/test_notebook_packaging.py -q --tb=short +.\.venv\Scripts\python.exe -m ruff check tau tests setup.py ../../../tools/run-cpu-ray-demo.py +.\.venv\Scripts\python.exe -m build --wheel ``` -Delivery paths (in priority order): - -1. **Template notebook** (recommended). -2. **Copy/paste bootstrap cell** for existing notebooks. -3. **Optional toolbar injection later** (out-of-scope for Slice 1; reintroduces Node/frontend extension complexity). - -Portability references: -- [ipywidgets Installation](https://ipywidgets.readthedocs.io/en/stable/user_install.html) -- [VS Code Jupyter notebooks](https://code.visualstudio.com/docs/datascience/jupyter-notebooks) -- [Colab widgets notebook](https://colab.research.google.com/notebooks/widgets.ipynb) - -### 5.6 Data contracts - -> Reviewer note: these contracts are design targets; endpoint/field exactness must be verified against repo handlers before implementation. - -**a. Run status** (proposed `RunStatus` shape; consumed by widget) - +- Final combined notebook regression suite: **215 passed in 10.21s**, exit **0**. + The twelve requested suites separately passed **199 tests**; the two new + HTTP-contract/packaging suites separately passed **16 tests**. +- Ruff: **All checks passed**, exit **0**. +- Clean wheel build: **tau-0.1.3-py3-none-any.whl**, exit **0**. + ZIP assertions verified 54 entries, six byte-identical frontend assets in + the package and `share/jupyter/labextensions/taugrid-jupyterlab`, exactly one + current remoteEntry in each, matching manifest references, auto-enable config + under `etc/jupyter/jupyter_server_config.d`, new shared Python modules, and + absence of deleted `_conformance.py`/`CliBackend`. Inspection exited **0**. + +Additional builds/checks: + +- `go build ./...` independently in `core`, `cli`, and `portal`: each exit **0**. +- `go test ./internal/portalapi` in `portal`: **ok**, 0.767s, exit **0**. + The subsequent uncached full-suite run also passed this changed package + (**ok**, 1.355s). +- `sdk/python/python/.venv/Scripts/python.exe scripts/check-license-headers.py` + from repository root: **License header check passed for 863 source files**, + exit **0**. +- `git diff --check`: exit **0** (Git prints Windows LF/CRLF conversion warnings, + not whitespace errors). + +### Broader-suite failures, compared against untouched HEAD + +**Do not interpret the scoped green checks as a green whole-repository suite.** + +- SDK `python -m pytest -q --tb=line`: **54 failed, 267 passed, 1 skipped**, + 21.76s, exit **1**. A HEAD snapshot using the same interpreter produced + **54 failed, 223 passed, 1 skipped**, 22.71s, exit **1**. The complete set of + 54 `FAILED` identifiers is identical. Failures include Windows inability to + execute POSIX stub executables, CP1252 fixture scripts read as UTF-8, CRLF + golden differences, and binary-discovery assumptions. No unrelated SDK + behavior/tests were changed to hide these failures. +- Portal `go test -count=1 ./...`: **16 failing tests** across `internal/cli`, + `internal/expapi`, and `internal/expcockpit`, exit **1**. The HEAD snapshot + produced the exact same 16 failing test identifiers in those packages. + Errors include unavailable Python/stub commands, Windows paths/file URI + handling, and frontend source assertions. `internal/portalapi` passes on + both trees. An earlier cached `go test ./...` also exited **1**. + +Baseline verification used a temporary `git archive HEAD` snapshot, not a new +branch or worktree. Unchanged tracked fixture/source bytes were copied from +the checkout to match Windows checkout line endings. Failure identifier sets +were compared mechanically (zero differences). Logs are in the local temporary +`taugrid-notebook-baseline-d575daf77c8e450392466055e308a6e0` directory: +`pytest-complete.log`, `pytest-working.log`, `portal-head.log`, +`portal-working.log`. Initial incomplete snapshot attempts are not the evidence +used for these comparisons. + +### Change inventory and deliberately unchanged behavior + +The full working-tree path inventory follows. The two deleted design screenshots +were already deleted before this task; their deletion and the accompanying user +note were preserved, not newly authored. Three old generated JavaScript chunks +were replaced by three current content-hashed chunks. New shared modules/tests +are included, even though they remain untracked until the user stages changes. + +```text +cli/internal/tensorboard/options.go +cli/internal/tensorboard/render.go +cli/internal/tensorboard/render_test.go +docs/design/assets/notebook-embed-e2e.png +docs/design/assets/notebook-panel-e2e.png +docs/design/notebook-plugin.md +portal/internal/portalapi/server.go +portal/internal/portalapi/tensorboardproxy.go +portal/internal/portalapi/tensorboardproxy_test.go +sdk/python/python/labextension/src/explorer.tsx +sdk/python/python/labextension/src/model.ts +sdk/python/python/labextension/src/submission.ts +sdk/python/python/labextension/src/view.tsx +sdk/python/python/labextension/src/widget.tsx +sdk/python/python/labextension/style/index.css +sdk/python/python/labextension/tests/console.test.cjs +sdk/python/python/setup.py +sdk/python/python/tau/_backend.py +sdk/python/python/tau/_conformance.py +sdk/python/python/tau/_kube_io.py +sdk/python/python/tau/_notebook_pkg.py +sdk/python/python/tau/_notebook_submit.py +sdk/python/python/tau/_profile.py +sdk/python/python/tau/_render.py +sdk/python/python/tau/jupyter/metrics.py +sdk/python/python/tau/jupyter/runs.py +sdk/python/python/tau/jupyter/server.py +sdk/python/python/tau/jupyter/submit.py +sdk/python/python/tau/labextension/package.json +sdk/python/python/tau/labextension/static/443.61bff902d30cd2765dca.js +sdk/python/python/tau/labextension/static/443.d6e72d826e8943f371c1.js +sdk/python/python/tau/labextension/static/543.5d8256a98e0f346f04a5.js +sdk/python/python/tau/labextension/static/543.d9b4f347a16974e8b031.js +sdk/python/python/tau/labextension/static/remoteEntry.348e0d93f291c3406bed.js +sdk/python/python/tau/labextension/static/remoteEntry.a8cb0ad006a83367d44b.js +sdk/python/python/tau/widgets/embed.py +sdk/python/python/tau/widgets/ipython.py +sdk/python/python/tau/widgets/kube.py +sdk/python/python/tau/widgets/panel.py +sdk/python/python/tau/widgets/status.py +sdk/python/python/tests/test_jupyter_contract.py +sdk/python/python/tests/test_jupyter_metrics.py +sdk/python/python/tests/test_jupyter_runs.py +sdk/python/python/tests/test_jupyter_submit.py +sdk/python/python/tests/test_notebook_loss_demo.py +sdk/python/python/tests/test_notebook_packaging.py +sdk/python/python/tests/test_widgets_core.py +sdk/python/python/tests/test_widgets_submit.py +site/content/en/docs/examples/notebook-plugin.md +tools/run-cpu-ray-demo.py ``` -metadata.{name,namespace} -status.{found,workloadKind,state,displayState,startupComplete,startupFailed} -rayJob.{rayClusterName,jobId,jobDeploymentStatus,jobStatus,reason,message} -workloads[].{name,queue,admitted,phase,reason,message} -pods[].{name,phase,node,ready,restarts} -metrics.gpuRuntime.{state,reason,nodesExpected,nodesScraped,devices[].{pod,gpu, - utilizationPercent,utilizationObserved, - framebufferUsedMiB,framebufferUsedObserved}} -diagnostics[].{code,severity,message,suggestion} -actions[].{description,command.shell} -``` - -**b. Loss curve** (proposed portal fetch) - -`GET {portal}/api/stellar/series?target=&metric=train/loss&max_points=N` - -``` -chart.{has_data,metric_name,series[].{run_id,values[].{step,value}}} -``` - -**c. Live loss without user import** - -Two channels, in precedence order: - -- **stdout convention**: parse `loss=` or `step= loss=`. -- **metrics file convention**: JSONL at `TAU_METRICS_PATH` (default `/data/metrics.jsonl`), e.g. `{"step":3,"loss":1.25}`. - -UI must label the active source to avoid ambiguity. - -**d. Ray dashboard URL** - -`{portal}/api/portal/ray/proxy///` (exact route to verify in repo). - -### 5.7 Ray dashboard framing constraints - -If Ray proxy responses include `X-Frame-Options: SAMEORIGIN`, browsers block cross-origin iframe embedding ([MDN: X-Frame-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options)). - -Design policy: - -- **Default:** open Ray dashboard in a new tab. -- **Embed only when proven embeddable** (same-origin/local URL explicitly provided). -- **Never render a “known-broken” iframe**. - ---- - -## 6. UI experience - -### 6.1 The panel - -One widget, vertical stack. Loss is the hero; GPU is directly below; lifecycle/Ray are drill-downs. - -``` -┌──────────────────────────────────────────────────────────────────────────────┐ -│ TauGrid │ -│ [Submit notebook] notebook analysis.ipynb profile ▾ queue ▾ │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ analysis-001 / ray [Running] RayJob │ -│ Running - 8/8 pods ready - queue research-gpu - updated 14:22:31 UTC │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ ▾ Loss (train/loss) │ -│ loss down 88.4% 0.9230 -> 0.1040 latest 0.1040 best 0.0982 │ -│ [line chart] │ -│ source: metrics file - 1240 points │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ ▾ GPU usage │ -│ avg util 87.3% gpus observed 8/8 nodes scraped 2/2 │ -│ [per-device bars with numeric percentages] │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ ▾ Ray dashboard │ -│ Open dashboard ↗ │ -│ (optional local port-forward command shown as convenience text) │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ ▸ Queue and pods │ -│ ▸ Diagnostics │ -└──────────────────────────────────────────────────────────────────────────────┘ -``` - -### 6.2 The “loss driven down” signature - -Primary summary line (words + numbers): - -`loss down 88.4% 0.9230 -> 0.1040` - -Rules: - -- `down` / `up` from first vs latest point. -- Warn color when loss increases. -- Fewer than two points: show “waiting for first steps”. - -### 6.3 States - -| State | Header | Loss pane | GPU pane | Default-open panes | -|---|---|---|---|---| -| No notebook resolved | muted badge, `notebook path not resolved` | hidden | hidden | submit controls + manual path override | -| Not submitted | muted badge, `not submitted` | hidden | hidden | submit controls | -| Queued | amber `Pending (not yet admitted)` | “no steps logged yet” | “no pods scheduled” | queue | -| Running | blue `Running` | live curve | live bars | loss, gpu, ray | -| Failed | red `Failed` | frozen last curve | last-known or unavailable | diagnostics | -| Complete | green `Complete` | final curve + delta | completed/no-live-samples message | loss, gpu, ray | - -### 6.4 Interaction model - -- **Submit** (primary): package notebook, resolve profile, render/apply workload. -- **Refresh** (secondary + auto-poll while active): visible/adjustable interval. -- **Stop watching**: stop polling without touching run. -- **Drill-downs**: collapsible panes, state remembered in widget state. -- **Export**: “Save HTML” for reports/PRs. - -### 6.5 Visual system - -- System UI font stack; monospace for ids/commands. -- Functional state colors only (running/ok/warn/error/idle). -- Dense operational layout (small but readable type). -- Accessibility: textual state labels, keyboard navigable panes, numeric labels on bars, SVG `aria-label`. - ---- - -## 7. Concrete API surface - -Template cell (platform-authored, not user-authored): - -```python -import tau.widgets as tg -tg.panel() -``` - -Programmatic usage for platform engineers/CI: - -```python -from tau.widgets import TauGridPanel - -panel = TauGridPanel( - notebook="analysis.ipynb", # optional; resolve from session if omitted - namespace="ray", - profile="training-8gpu", # optional; resolve from cluster defaults if omitted - queue="research-gpu", # optional; fallback to workspace/profile default - portal_url="https://portal.contoso.com", -) -``` - -`TauGridPanel.submit()` uses `KubernetesBackend` (no CLI dependency), renders workload object(s), applies via Kubernetes APIs, and watches status/metrics. - ---- - -## 8. Slicing - -**Slice 1 - walking skeleton (end-to-end demoable)** - -- Panel renders in notebook output. -- Submit packages current notebook and applies workload via Kubernetes client. -- Header + loss (metrics-file convention) + GPU + Ray link render. -- Correct states: not-submitted / queued / running / failed. -- Renderer conformance harness exists (Go vs Python normalized comparison). -- Offline tests run with injected transports. -- **Required gating task:** replace §1 assumptions with grep-backed `file:line` citations. - -**Slice 2 - live and durable** - -- Auto-poll repaint and interval controls. -- Stdout loss parser. -- Portal-series fallback for loss. -- Pod logs integration. -- Save HTML export. -- Pod list/framebuffer summaries. - -**Slice 3 - richer surfaces** - -- Optional toolbar injection path. -- Deeper portal deep-links. -- Broader submit shapes beyond Slice-1 constraints. - ---- - -## 9. Risks and open questions - -**Blocking (decide before Slice 1):** - -1. **Runtime image capability:** does the runtime image include `nbconvert` (or `papermill`)? - References: [nbconvert Execute API](https://nbconvert.readthedocs.io/en/latest/execute_api.html), [Papermill Documentation](https://papermill.readthedocs.io/en/latest/). -2. **Loss intent:** “loss driven down” means a per-step curve (single-run history), not a 2D loss landscape. Confirm expectation. -3. **Packaging:** ship inside `tau` wheel vs separate `tau-notebook` package. -4. **Repository verification debt:** assumptions in §1/§5 must be converted to exact `file:line` evidence before implementation merge. - -**Top risks introduced by the chosen decisions:** - -- **Renderer drift:** Python renderer diverges from Go behavior. - Mitigation: conformance tests in Slice 1. -- **Loss capture convention fragility:** unsupported print format yields empty curve. - Mitigation: explicit docs + visible source label + metrics-file first-class path. -- **Notebook path resolution edge cases:** missing session, renamed file, Colab differences. - Mitigation: manual path override + explicit unresolved state. -- **Cross-surface widget variance:** Notebook/JupyterLab/VS Code/Colab differ in widget/runtime behavior. - Mitigation: primary-support stance (Notebook), compatibility matrix in docs, smoke tests per surface. - -**Deferrable (defaults chosen):** - -- Dark mode (default light). -- Total GPU capacity display (used memory first). -- Multi-run overlay (later slice). - ---- - -## 10. What I need from you - -1. Confirm runtime-image execution path (`nbconvert` vs `papermill`) and base image update plan. -2. Confirm loss interpretation (curve vs landscape). -3. Confirm packaging (`tau` wheel vs separate package). -4. Confirm Ray behavior default (link-first; embed only when embeddable). -5. Assign owner to complete grep-backed `file:line` verification pass for §1 and §5 assumptions. - -Decisions already fixed and not for re-litigation: **no CLI dependency**, **no user-authored import**, **job is current notebook**, **ipywidgets (not native JupyterLab extension)**. - ---- - -## Companion artifacts -- `notebook-plugin-storymap.md` - backbone, tasks, sliced stories -- `notebook-plugin-backlog.md` / `.csv` - WSJF-ranked backlog -- `notebook-plugin-slice-1-acceptance-criteria.md` - Given/When/Then for Slice 1 \ No newline at end of file +Left alone: the production SDK CLI executor, existing Portal/Ray proxies, +notebook demo contents, current screenshots and site copies, browser E2E runner, +runtime image/Docker dependency choices, and unrelated failing Windows suites. +Legacy widgets remain supported rather than being silently removed. Existing +loss evidence bounds/cache/source rules and the table/sidebar/toolbar/tab +contracts are retained, with targeted correctness changes described above. + +### Parent-owned live verification still required + +Offline tests do not re-certify the earlier live results. Repeat all five +journeys: auto-loaded runs table in `#jp-left-stack`, input+datalist namespace +auto-selection, run detail with **40 real points and one polyline**, logs, and +review → confirmation → actual submission. Verify JupyterLab 4 and Notebook 7 +activation, authentication/RBAC, actual queue admission and execution, payload +mounts and remote driver versus submitter placement, final logs/metrics, and +changed-profile rejection followed by a fresh successful review. Rebuild the +runtime wheel/image before live execution. UID pre/post checks narrow observed +races but are not atomic Kubernetes snapshot guarantees. diff --git a/examples/notebook-loss-curve-demo.ipynb b/examples/notebook-loss-curve-demo.ipynb new file mode 100644 index 00000000..89170f62 --- /dev/null +++ b/examples/notebook-loss-curve-demo.ipynb @@ -0,0 +1,9 @@ +{ + "cells": [ + {"cell_type": "markdown", "metadata": {}, "source": ["# Deterministic CPU loss curve\n", "Pure Python gradient descent fits y = 2x + 1. Forty real observations span about forty seconds so the existing status poll can collect multiple windows. No downloads or GPU packages are needed. Submit using an explicitly reviewed CPU profile.\n"]}, + {"cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": ["import time\n", "\n", "inputs = (-2.0, -1.0, 0.0, 1.0, 2.0)\n", "targets = tuple(2.0 * value + 1.0 for value in inputs)\n", "weight, bias = 0.0, 0.0\n", "for step in range(40):\n", " errors = tuple(weight * value + bias - target for value, target in zip(inputs, targets))\n", " loss = sum(error * error for error in errors) / len(inputs)\n", " print(f'step={step} loss={loss:.12g}', flush=True)\n", " weight -= 0.1 * 2.0 * sum(error * value for error, value in zip(errors, inputs)) / len(inputs)\n", " bias -= 0.1 * 2.0 * sum(errors) / len(inputs)\n", " time.sleep(1.0)\n", "print(f'Finished: weight={weight:.6f}, bias={bias:.6f}', flush=True)\n"]} + ], + "metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, "language_info": {"name": "python", "version": "3.11"}}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebook-ray-cpu-demo.ipynb b/examples/notebook-ray-cpu-demo.ipynb new file mode 100644 index 00000000..e85c733d --- /dev/null +++ b/examples/notebook-ray-cpu-demo.ipynb @@ -0,0 +1,80 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# TauGrid CPU Ray demo\n", + "\n", + "Submitted through the TauGrid JupyterLab plugin. It runs on a CPU-only Ray worker\n", + "profile, so it needs no GPU quota.\n" + ] + }, + { + "cell_type": "code", + "id": "connect", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "from math import sqrt\n", + "\n", + "import ray\n", + "\n", + "# On the cluster the RayJob starts the cluster, so connect to it; locally, start one.\n", + "try:\n", + " ray.init(address=\"auto\", ignore_reinit_error=True, logging_level=\"ERROR\")\n", + "except Exception:\n", + " ray.init(ignore_reinit_error=True, logging_level=\"ERROR\")\n", + "\n", + "resources = ray.cluster_resources()\n", + "print(\"ray\", ray.__version__)\n", + "print(\"nodes\", len(ray.nodes()))\n", + "print(\"cpus\", resources.get(\"CPU\"))\n", + "print(\"gpus\", resources.get(\"GPU\", 0))\n" + ] + }, + { + "cell_type": "code", + "id": "workload", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "@ray.remote(num_cpus=1)\n", + "def busy_sum(n: int) -> float:\n", + " return sum(sqrt(i) for i in range(n))\n", + "\n", + "started = time.time()\n", + "results = ray.get([busy_sum.remote(2_000_000) for _ in range(8)])\n", + "elapsed = time.time() - started\n", + "print(f\"CPU_DEMO tasks={len(results)} total={sum(results):.2f} elapsed={elapsed:.2f}s\")\n" + ] + }, + { + "cell_type": "code", + "id": "done", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"CPU_DEMO_OK\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebook-ray-cpu-demo.md b/examples/notebook-ray-cpu-demo.md new file mode 100644 index 00000000..f5ec88b6 --- /dev/null +++ b/examples/notebook-ray-cpu-demo.md @@ -0,0 +1,98 @@ +# CPU Ray demo for the TauGrid notebook plugin + +Runs a CPU-only Ray job through the TauGrid JupyterLab plugin, so it needs no GPU +quota, and shows the plugin tracking it from admission to completion. + +- Notebook: [notebook-ray-cpu-demo.ipynb](notebook-ray-cpu-demo.ipynb) +- Driver: `tools/run-cpu-ray-demo.py` (drives the same endpoints the panel uses) + +## What the job does + +Connects to the Ray cluster the RayJob starts, then runs eight `@ray.remote` +CPU tasks and prints the Ray version, node and CPU counts, GPU count, and the +result. It finishes with `CPU_DEMO_OK`. + +## Prerequisites + +1. **A notebook runtime image.** The platform's AI runtime image ships Ray but no + notebook executor, and cluster pods cannot reach PyPI on a locked-down + network, so build the executor in: + + ```bash + python -m pip download --dest images/notebook-runtime/wheels \ + --platform manylinux2014_x86_64 --python-version 3.12 --implementation cp --abi cp312 \ + --only-binary=:all: "nbformat>=5.10" "nbconvert>=7.16" "ipykernel>=6.29" \ + "pexpect>4.6" "ptyprocess" + docker build -t taugrid-notebook-runtime:local images/notebook-runtime + ``` + + The extra `pexpect`/`ptyprocess` downloads are required because pip + evaluates `sys_platform` markers against the host, not the target platform. + +2. **Point the server at it** and enable submission: + + ```bash + TAUGRID_RUNTIME_IMAGE=taugrid-notebook-runtime:local \ + TAUGRID_SUBMISSION_ENABLED=1 \ + jupyter server --no-browser --port=8888 --ServerApp.token= + ``` + +3. **A CPU workload profile.** The shipped profiles all request GPUs, so add a + CPU one to the TauCluster: + + ```bash + kubectl patch cluster cluster --type=json -p '[{"op":"add","path":"/spec/workloadProfiles/-","value":{ + "name":"azure.research.cpu.small","description":"CPU-only Ray workers.", + "mode":"fixed","workerCount":1,"gpusPerWorker":0,"defaultLocalQueue":"jobqueue", + "executionTarget":"singleCluster","placement":"independent", + "applicability":{"lanes":["training"],"teams":["research"],"namespaces":["tau-notebook-e2e"]}, + "priorities":{"podPriorityClassName":"taugrid-default","workloadPriorityClassName":"taugrid-default"}}}]' + ``` + +4. **A LocalQueue in the run namespace**, and the namespace label the ClusterQueue + selects on. Without both, the workload stays unadmitted: + + ```bash + kubectl create namespace tau-notebook-e2e --dry-run=client -o yaml | kubectl apply -f - + kubectl label ns tau-notebook-e2e tau.azure.com/workspace=tau-notebook-e2e --overwrite + kubectl apply -f - <<'YAML' + apiVersion: kueue.x-k8s.io/v1beta2 + kind: LocalQueue + metadata: + name: jobqueue + namespace: tau-notebook-e2e + spec: + clusterQueue: jobqueue + YAML + ``` + +## Run it + +```bash +python tools/run-cpu-ray-demo.py --token --name cpu-demo --timeout 900 +``` + +Expected: the plan resolves `azure.research.cpu.small` on queue `jobqueue`, then +`state=queued` -> `state=running` -> `state=complete` with `RESULT: SUCCEEDED`. + +In the JupyterLab panel: open **TauGrid: Open runs**, set the namespace, press +**List runs**, and open the run. The detail tab shows Finished, Admitted, the +queue, and 2/2 pods; **Open logs** shows the bounded pod log snapshot. + +## Notes + +- The RayJob sets `ttlSecondsAfterFinished: 15`, so pods are removed 15 seconds + after the job ends. Patch the RayJob to a larger TTL if you want to read the + executed notebook or the Ray driver log afterwards: + + ```bash + kubectl patch rayjob -n --type=merge -p '{"spec":{"ttlSecondsAfterFinished":900}}' + kubectl exec -n -c ray-head -- \ + cat /tmp/ray/session_latest/logs/job-driver-*.log + kubectl exec -n -c ray-head -- python3 -c \ + "import json; nb=json.load(open('/data/analysis.executed.ipynb')); print(''.join(t for c in nb['cells'] for o in c.get('outputs',[]) for t in o.get('text',[])))" + ``` + +- The entrypoint still runs a `pip install nbconvert ipykernel` preamble. With a + runtime image that already contains them it is a no-op; on an image without + them it fails, because pods have no PyPI access here. diff --git a/images/notebook-runtime/Dockerfile b/images/notebook-runtime/Dockerfile new file mode 100644 index 00000000..795ee589 --- /dev/null +++ b/images/notebook-runtime/Dockerfile @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# TauGrid notebook runtime: ray plus the notebook executor. +# +# The base AI runtime image ships ray but no notebook execution stack, and both +# pods and image builds can be cut off from PyPI on a locked-down network, so the +# executor is baked in at build time. Populate wheels/ (gitignored) to build +# offline: +# +# python -m pip download --dest images/notebook-runtime/wheels \ +# "nbformat>=5.10" "nbconvert>=7.16" "ipykernel>=6.29" +# docker build -t taugrid-notebook-runtime:local images/notebook-runtime +# +# With an empty wheels/ the build falls back to the package index. Publish the +# image through the normal pipeline; notebook submission stays disabled until a +# runtime image like this is published and recorded. + +FROM mcr.microsoft.com/aks/ai-runtime/ray:py3.12-ray2.56.0-cuda13.0 + +COPY wheels/ /wheels/ + +RUN if ls /wheels/*.whl >/dev/null 2>&1; then \ + python3 -m pip install --no-cache-dir --no-index --find-links=/wheels \ + "nbformat>=5.10" "nbconvert>=7.16" "ipykernel>=6.29"; \ + else \ + python3 -m pip install --no-cache-dir \ + "nbformat>=5.10" "nbconvert>=7.16" "ipykernel>=6.29"; \ + fi + +# Prove the executor is importable at build time, so a bad image fails here +# rather than in a user's job. +RUN python3 -c "import nbformat, nbconvert, ipykernel; print('notebook executor ready')" diff --git a/images/notebook-runtime/wheels/.gitignore b/images/notebook-runtime/wheels/.gitignore new file mode 100644 index 00000000..47c4b208 --- /dev/null +++ b/images/notebook-runtime/wheels/.gitignore @@ -0,0 +1,2 @@ +# Vendored only for local/offline image builds; never committed. +*.whl diff --git a/images/notebook-runtime/wheels/.gitkeep b/images/notebook-runtime/wheels/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/scripts/license-header-exclusions.txt b/scripts/license-header-exclusions.txt index ac89cb2a..1fcbe37a 100644 --- a/scripts/license-header-exclusions.txt +++ b/scripts/license-header-exclusions.txt @@ -17,3 +17,8 @@ charts/adx-mon/charts/ # Generated portal bundles contain third-party JavaScript; licenses are shipped # alongside them in assets/THIRD_PARTY_LICENSES.txt. Check first-party source. portal/internal/portalapi/assets/assets/ + +# Generated JupyterLab extension bundles contain third-party JavaScript; +# licenses are shipped alongside them in static/third-party-licenses.json. +# Check first-party source instead. +sdk/python/python/tau/labextension/static/ diff --git a/sdk/python/python/examples/nb_helpers.py b/sdk/python/python/examples/nb_helpers.py new file mode 100644 index 00000000..0e8b723d --- /dev/null +++ b/sdk/python/python/examples/nb_helpers.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""A sibling module a notebook can ship with, chosen in the submit review.""" + +VALUE = 41 + + +def total(values): + return sum(values) + VALUE diff --git a/sdk/python/python/examples/notebook-button-e2e.ipynb b/sdk/python/python/examples/notebook-button-e2e.ipynb new file mode 100644 index 00000000..e4c76c54 --- /dev/null +++ b/sdk/python/python/examples/notebook-button-e2e.ipynb @@ -0,0 +1,82 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b0e2d001", + "metadata": {}, + "source": [ + "# TauGrid plugin button E2E\n", + "\n", + "The panel below carries the real interactive Submit button. Its submit is\n", + "backed by an in-kernel recording fake, so a browser click can be asserted\n", + "end-to-end without a live cluster: the click drives the same handler the\n", + "button registers, and the status line repaints into run view." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b0e2d002", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext tau.widgets.ipython" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b0e2d003", + "metadata": {}, + "outputs": [], + "source": [ + "from tau.widgets.panel import TauGridPanel\n", + "from tau._backend import SubmittedRun\n", + "from IPython.display import display\n", + "\n", + "recorded = []\n", + "\n", + "def fake_submit(notebook=None, **kwargs):\n", + " recorded.append(notebook)\n", + " panel.run_name = \"submitted-button-demo\" # status repaint proves the click ran\n", + " return SubmittedRun(name=panel.run_name, namespace=panel.namespace, kind=\"RayJob\")\n", + "\n", + "panel = TauGridPanel(namespace=\"ray\")\n", + "panel.set_notebook_path(\"examples/notebook-button-e2e.ipynb\")\n", + "panel.submit = fake_submit\n", + "display(panel.build())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b0e2d004", + "metadata": {}, + "outputs": [], + "source": [ + "recorded" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/sdk/python/python/examples/notebook-embed.ipynb b/sdk/python/python/examples/notebook-embed.ipynb new file mode 100644 index 00000000..602c3f2d --- /dev/null +++ b/sdk/python/python/examples/notebook-embed.ipynb @@ -0,0 +1,70 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "embed-intro", + "metadata": {}, + "source": [ + "# TensorBoard-style TauGrid embed\n", + "\n", + "`%taugrid --embed` serves the TauGrid portal run view through a local\n", + "same-origin proxy and frames it in this cell - the same pattern the\n", + "TensorBoard notebook plugin uses. Set `PORTAL` to your portal URL.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "embed-ext", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext tau.widgets.ipython\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "embed-run", + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import display\n", + "\n", + "PORTAL = 'http://127.0.0.1:8090' # your TauGrid portal base URL\n", + "RUN = 'notebook-embed-demo'\n", + "NAMESPACE = 'tau-notebook-e2e'\n", + "\n", + "view = get_ipython().run_line_magic(\n", + " 'taugrid',\n", + " f'--embed --name={RUN} --namespace={NAMESPACE} --portal={PORTAL}',\n", + ")\n", + "print('proxied:', view.proxied)\n", + "print('iframe url:', view.url)\n", + "print('portal link:', view.portal_url + view.path)\n", + "display(view)\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/sdk/python/python/examples/notebook-files-demo.ipynb b/sdk/python/python/examples/notebook-files-demo.ipynb new file mode 100644 index 00000000..56e7ce6c --- /dev/null +++ b/sdk/python/python/examples/notebook-files-demo.ipynb @@ -0,0 +1,42 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Shipping files with the notebook\n", + "\n", + "This notebook imports a sibling module. Select `nb_helpers.py` under\n", + "**Files to ship** in the submit review, or the import fails on the cluster.\n" + ] + }, + { + "cell_type": "code", + "id": "use", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import nb_helpers\n", + "\n", + "print(\"HELPER_VALUE\", nb_helpers.VALUE)\n", + "print(\"HELPER_TOTAL\", nb_helpers.total([1, 1]))\n", + "print(\"HELPER_OK\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/sdk/python/python/examples/notebook-load-job.ipynb b/sdk/python/python/examples/notebook-load-job.ipynb new file mode 100644 index 00000000..33d4e032 --- /dev/null +++ b/sdk/python/python/examples/notebook-load-job.ipynb @@ -0,0 +1,165 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "load-job-intro", + "metadata": {}, + "source": [ + "# Load an existing RayJob and check its status\n", + "\n", + "This notebook attaches to a RayJob that already exists in a Kubernetes\n", + "cluster and reads its normalized status. It runs entirely offline: a small\n", + "in-kernel fake cluster client stands in for the Kubernetes API, so the\n", + "example needs no real cluster and no network access.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "load-job-ext", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext tau.widgets.ipython" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "load-job-fakes", + "metadata": {}, + "outputs": [], + "source": [ + "from tau.widgets.panel import TauGridPanel\n", + "from tau.widgets.kube import ClusterClient\n", + "from tau.widgets.status import list_runs\n", + "\n", + "RAYJOB = {\n", + " \"metadata\": {\n", + " \"name\": \"demo-rayjob\",\n", + " \"labels\": {\"kueue.x-k8s.io/queue-name\": \"research-gpu\"},\n", + " },\n", + " \"status\": {\n", + " \"jobStatus\": \"RUNNING\",\n", + " \"rayClusterName\": \"demo-rayjob-raycluster\",\n", + " \"jobId\": \"raysubmit_123\",\n", + " \"jobDeploymentStatus\": \"Running\",\n", + " \"conditions\": [{\"type\": \"Admitted\", \"status\": \"True\"}],\n", + " },\n", + "}\n", + "\n", + "PODS = {\n", + " \"items\": [\n", + " {\n", + " \"metadata\": {\n", + " \"name\": \"demo-rayjob-head-abc\",\n", + " \"labels\": {\"ray.io/node-type\": \"head\"},\n", + " },\n", + " \"spec\": {\"nodeName\": \"gpu-node-1\"},\n", + " \"status\": {\n", + " \"phase\": \"Running\",\n", + " \"conditions\": [{\"type\": \"Ready\", \"status\": \"True\"}],\n", + " \"containerStatuses\": [{\"restartCount\": 1, \"ready\": True}],\n", + " },\n", + " }\n", + " ]\n", + "}\n", + "\n", + "\n", + "class FakeCustomApi:\n", + " \"\"\"Offline stand-in for the Kubernetes CustomObjectsApi.\"\"\"\n", + "\n", + " def __init__(self, rayjob=RAYJOB):\n", + " self.rayjob = rayjob\n", + "\n", + " def get_namespaced_custom_object(self, **kwargs):\n", + " return self.rayjob\n", + "\n", + " def list_namespaced_custom_object(self, **kwargs):\n", + " return {\"items\": [self.rayjob] if self.rayjob else []}\n", + "\n", + "\n", + "class FakeCoreApi:\n", + " \"\"\"Offline stand-in for the Kubernetes CoreV1Api pod listing.\"\"\"\n", + "\n", + " def __init__(self, pods=PODS[\"items\"]):\n", + " self.pods = pods\n", + "\n", + " def list_namespaced_pod(self, namespace, label_selector=None, **kwargs):\n", + " return {\"items\": self.pods}\n", + "\n", + "\n", + "def build_panel():\n", + " \"\"\"Return a TauGridPanel wired to the in-kernel fake cluster.\"\"\"\n", + " client = ClusterClient(custom=FakeCustomApi(), core=FakeCoreApi())\n", + " return TauGridPanel(namespace=\"ray\", client=client)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "load-job-load", + "metadata": {}, + "outputs": [], + "source": [ + "panel = build_panel()\n", + "status = panel.load('demo-rayjob', namespace='ray')\n", + "print(status.state, status.ready_pods, status.total_pods, status.queue)\n", + "assert status.existing is True and status.state == 'running' and status.ready_pods == 1 and status.total_pods == 1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "load-job-check", + "metadata": {}, + "outputs": [], + "source": [ + "status = panel.check_status()\n", + "print([d.code for d in status.diagnostics])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "load-job-render", + "metadata": {}, + "outputs": [], + "source": [ + "panel" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "load-job-list", + "metadata": {}, + "outputs": [], + "source": [ + "runs = list_runs(panel.client, namespace='ray')\n", + "print([run.name for run in runs])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/sdk/python/python/examples/notebook-panel.ipynb b/sdk/python/python/examples/notebook-panel.ipynb new file mode 100644 index 00000000..5d0fbfb4 --- /dev/null +++ b/sdk/python/python/examples/notebook-panel.ipynb @@ -0,0 +1,81 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6fb6c9f0", + "metadata": {}, + "source": [ + "# TauGrid notebook panel\n", + "\n", + "Run this notebook to load the TauGrid plugin and render its panel.\n", + "The first cell is the platform-authored bootstrap; the end user writes\n", + "no imports (design D2)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc1a60d6", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext tau.widgets.ipython" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8b81e6c6", + "metadata": {}, + "outputs": [], + "source": [ + "%taugrid" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e1b58a7", + "metadata": {}, + "outputs": [], + "source": [ + "# Inspect the panel state without a live cluster (offline demo).\n", + "from tau.widgets.panel import TauGridPanel\n", + "from tau.widgets.metrics import MetricSeries, MetricSample\n", + "\n", + "panel = TauGridPanel(run_name=\"demo\")\n", + "panel.set_loss(MetricSeries([MetricSample(0, 0.9230), MetricSample(10, 0.1040)]))\n", + "panel" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2cdf7a7-f787-47f9-b394-fed67ad1aa97", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/sdk/python/python/labextension/.gitignore b/sdk/python/python/labextension/.gitignore new file mode 100644 index 00000000..a2d9a5e2 --- /dev/null +++ b/sdk/python/python/labextension/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +lib/ +tsconfig.tsbuildinfo diff --git a/sdk/python/python/labextension/package-lock.json b/sdk/python/python/labextension/package-lock.json new file mode 100644 index 00000000..db76fdb7 --- /dev/null +++ b/sdk/python/python/labextension/package-lock.json @@ -0,0 +1,5913 @@ +{ + "name": "taugrid-jupyterlab", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "taugrid-jupyterlab", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@jupyterlab/application": "^4.0.0", + "@jupyterlab/apputils": "^4.0.0", + "@jupyterlab/coreutils": "^6.0.0", + "@jupyterlab/launcher": "^4.0.0", + "@jupyterlab/notebook": "^4.0.0", + "@jupyterlab/services": "^7.0.0", + "@lumino/widgets": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@jupyterlab/builder": "^4.0.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "rimraf": "^5.0.0", + "typescript": "~5.3.0" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@antfu/install-pkg/-/install-pkg-2.0.1.tgz", + "integrity": "sha1-vbw9Io5vZFxYPtk1RfzdX1ovDKI=", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.7.0", + "tinyexec": "^1.2.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha1-yiA1sP7+lWqGdv8Maa9z5gX82B8=", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha1-6DoaJwTwxeSedZKyFAMaD0o01+U=", + "license": "Apache-2.0" + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha1-aWt0AxLGqWLhRWe0mjZhtZJLxa4=", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/commands/-/commands-6.11.0.tgz", + "integrity": "sha1-IZTW/K2e14fcxCZn2w4FQ/qy4O8=", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha1-sXW1n83o3W5WO3/u6LvtgZY6lJE=", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha1-djykGu6BuyQxvlXjz8x8yOkUIaM=", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-html/-/lang-html-6.4.12.tgz", + "integrity": "sha1-yl3A90HB6BkYK86dA7BzVSFysbc=", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha1-YB1bPXdKSpl9EWR8y2wFcCxUvVs=", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha1-ueprLwOD7WiV+ueIjAMiVBU48Qo=", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha1-BUsWBnEwZmfiXYA4UoYEmEGDYXk=", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", + "integrity": "sha1-UwuRRCwDXKXU6lE1wEmbLBXDmHI=", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha1-vcQ50ZXI5zUTvFuXGpmle1yZ7lU=", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha1-N8mTBxYRAVaGWpXFSKoO71VShjo=", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha1-aRRuaz6Plh7xSQWa7Lnge/17870=", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha1-Sb+/bPMVFqmeZ02po5n0QmEBqVo=", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-wast": { + "version": "6.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", + "integrity": "sha1-0rFBdeXoDXh4y7sp4g7JDcEtOis=", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha1-4+eG4aif3JUg7+dcHW094cQOuRw=", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha1-AecP1ao6igZ/8d/sddW2OUzfoFg=", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz", + "integrity": "sha1-SigITMh50prE1/L6N5+lBwKsPmY=", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha1-hB/HM2dDidkf5JocNAJ607q98QU=", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha1-k6SeshwCbeRzxPw6BHuP1neEsPA=", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/state/-/state-6.7.4.tgz", + "integrity": "sha1-xA/2bBMu6fj25g46CSz840bj5MY=", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.11", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@codemirror/view/-/view-6.43.11.tgz", + "integrity": "sha1-sPq8UZs1fXG0gfDSR89gndBms70=", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha1-HVcr+74Ut3BOC6Dzm3SBW4SHDXA=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "5.15.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz", + "integrity": "sha1-7NpXErYayFLHYNizx5yWrcpVVOU=", + "hasInstallScript": true, + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha1-qw6epoHWyKEhTzDNdB/jogzFf1c=", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha1-//EcUoSQsRmiUKzjzzWgp44YIxc=", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha1-s3Znt7wYHBaHgiWbq0JHT79StVA=", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha1-Y0Khn0Q0dRjJPkOxrGnes8Rlah8=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha1-eg7mAfYPmaIMfHxf8MgDiMEYm9Y=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha1-shg1y9Nttla4V8KtAuvUE8wTqbo=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha1-9MZj6GLwbcmMpNRThixGkCeJoY0=", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha1-2xXWeByTHzolGj2sOVAcmKYIL9A=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jupyter/react-components": { + "version": "0.16.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyter/react-components/-/react-components-0.16.7.tgz", + "integrity": "sha1-lJJmR6NXhAnGXWnVtEyGywyozqs=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyter/web-components": "^0.16.7", + "react": ">=17.0.0 <19.0.0" + } + }, + "node_modules/@jupyter/web-components": { + "version": "0.16.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyter/web-components/-/web-components-0.16.7.tgz", + "integrity": "sha1-zTR8Sh3NqVl+9AX5Tie/z+kg0bY=", + "license": "BSD-3-Clause", + "dependencies": { + "@microsoft/fast-colors": "^5.3.1", + "@microsoft/fast-element": "^1.12.0", + "@microsoft/fast-foundation": "^2.49.4", + "@microsoft/fast-web-utilities": "^5.4.1" + } + }, + "node_modules/@jupyter/ydoc": { + "version": "4.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyter/ydoc/-/ydoc-4.1.1.tgz", + "integrity": "sha1-sRkzzs+Ez8iK9Kv/rgFJTlba5Zw=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/nbformat": "^3.0.0 || ^4.0.0-alpha.21 || ^4.0.0", + "@lumino/coreutils": "^1.11.0 || ^2.0.0", + "@lumino/disposable": "^1.10.0 || ^2.0.0", + "@lumino/signaling": "^1.10.0 || ^2.0.0", + "y-protocols": "^1.0.5", + "yjs": "^13.5.40" + } + }, + "node_modules/@jupyterlab/application": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/application/-/application-4.6.3.tgz", + "integrity": "sha1-/11gE2xAsRALzQuqyato9Barqr0=", + "license": "BSD-3-Clause", + "dependencies": { + "@fortawesome/fontawesome-free": "^5.12.0", + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/docregistry": "^4.6.3", + "@jupyterlab/rendermime": "^4.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/statedb": "^4.6.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/algorithm": "^2.0.4", + "@lumino/application": "^2.4.9", + "@lumino/commands": "^2.3.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/messaging": "^2.0.4", + "@lumino/polling": "^2.1.5", + "@lumino/properties": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0" + } + }, + "node_modules/@jupyterlab/apputils": { + "version": "4.7.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/apputils/-/apputils-4.7.3.tgz", + "integrity": "sha1-ZLCiQf9vP0/q/SgmtEoTJF4Grjk=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/settingregistry": "^4.6.3", + "@jupyterlab/statedb": "^4.6.3", + "@jupyterlab/statusbar": "^4.6.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@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/messaging": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/virtualdom": "^2.0.4", + "@lumino/widgets": "^2.8.0", + "@types/react": "^18.0.26", + "react": "^18.2.0", + "sanitize-html": "~2.12.1" + } + }, + "node_modules/@jupyterlab/attachments": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/attachments/-/attachments-4.6.3.tgz", + "integrity": "sha1-cTUZU6SQbUvqFNJUGgNnCizOlO0=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/nbformat": "^4.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/rendermime": "^4.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@lumino/disposable": "^2.1.5", + "@lumino/signaling": "^2.1.5" + } + }, + "node_modules/@jupyterlab/builder": { + "version": "4.5.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/builder/-/builder-4.5.10.tgz", + "integrity": "sha1-ss/DwvMLXdeCH9wYCe3RdY30MhM=", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/algorithm": "^2.0.4", + "@lumino/application": "^2.4.8", + "@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/messaging": "^2.0.4", + "@lumino/properties": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/virtualdom": "^2.0.4", + "@lumino/widgets": "^2.7.5", + "ajv": "^8.12.0", + "commander": "^9.4.1", + "css-loader": "^6.7.1", + "duplicate-package-checker-webpack-plugin": "^3.0.0", + "fs-extra": "^10.1.0", + "glob": "~7.1.6", + "license-webpack-plugin": "^4.0.2", + "mini-css-extract-plugin": "^2.7.0", + "mini-svg-data-uri": "^1.4.4", + "path-browserify": "^1.0.0", + "process": "^0.11.10", + "source-map-loader": "~1.0.2", + "style-loader": "~3.3.1", + "supports-color": "^7.2.0", + "terser-webpack-plugin": "^5.3.7", + "webpack": "^5.76.1", + "webpack-cli": "^5.0.1", + "webpack-merge": "^5.8.0", + "webpack-sources": "^3.4.1", + "worker-loader": "^3.0.2" + }, + "bin": { + "build-labextension": "lib/build-labextension.js" + } + }, + "node_modules/@jupyterlab/cells": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/cells/-/cells-4.6.3.tgz", + "integrity": "sha1-DlK10g9sUEMutjJlU+sgDk4QfGA=", + "license": "BSD-3-Clause", + "dependencies": { + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.14", + "@jupyter/ydoc": "^4.0.0", + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/attachments": "^4.6.3", + "@jupyterlab/codeeditor": "^4.6.3", + "@jupyterlab/codemirror": "^4.6.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/documentsearch": "^4.6.3", + "@jupyterlab/filebrowser": "^4.6.3", + "@jupyterlab/nbformat": "^4.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/outputarea": "^4.6.3", + "@jupyterlab/rendermime": "^4.6.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/toc": "^6.6.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/algorithm": "^2.0.4", + "@lumino/coreutils": "^2.2.2", + "@lumino/domutils": "^2.0.4", + "@lumino/dragdrop": "^2.1.8", + "@lumino/messaging": "^2.0.4", + "@lumino/polling": "^2.1.5", + "@lumino/signaling": "^2.1.5", + "@lumino/virtualdom": "^2.0.4", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/codeeditor": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/codeeditor/-/codeeditor-4.6.3.tgz", + "integrity": "sha1-XQuZUzfrr46mLp6fWrKb9/tykPU=", + "license": "BSD-3-Clause", + "dependencies": { + "@codemirror/state": "^6.5.4", + "@jupyter/ydoc": "^4.0.0", + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/nbformat": "^4.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/statusbar": "^4.6.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/dragdrop": "^2.1.8", + "@lumino/messaging": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/codemirror": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/codemirror/-/codemirror-4.6.3.tgz", + "integrity": "sha1-eMco02lzW3wkN3kM73R7A6HPVk0=", + "license": "BSD-3-Clause", + "dependencies": { + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/commands": "^6.10.2", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-wast": "^6.0.2", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/language": "^6.12.1", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.14", + "@jupyter/ydoc": "^4.0.0", + "@jupyterlab/codeeditor": "^4.6.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/documentsearch": "^4.6.3", + "@jupyterlab/nbformat": "^4.6.3", + "@jupyterlab/translation": "^4.6.3", + "@lezer/common": "^1.2.1", + "@lezer/generator": "^1.7.0", + "@lezer/highlight": "^1.2.0", + "@lezer/markdown": "^1.3.0", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/signaling": "^2.1.5", + "yjs": "^13.5.40" + } + }, + "node_modules/@jupyterlab/coreutils": { + "version": "6.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/coreutils/-/coreutils-6.6.3.tgz", + "integrity": "sha1-K1uTWfxW8QpgRlEEZNnsD+yQDIo=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/signaling": "^2.1.5", + "minimist": "~1.2.0", + "path-browserify": "^1.0.0", + "url-parse": "~1.5.4" + } + }, + "node_modules/@jupyterlab/docmanager": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/docmanager/-/docmanager-4.6.3.tgz", + "integrity": "sha1-USpN50r4eCKcCINxSCMn8Az9pDE=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/docregistry": "^4.6.3", + "@jupyterlab/rendermime": "^4.6.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/statedb": "^4.6.3", + "@jupyterlab/statusbar": "^4.6.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/algorithm": "^2.0.4", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/messaging": "^2.0.4", + "@lumino/polling": "^2.1.5", + "@lumino/properties": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/docregistry": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/docregistry/-/docregistry-4.6.3.tgz", + "integrity": "sha1-JlxOTqqRH18u18jBMEfzEAKcxzA=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyter/ydoc": "^4.0.0", + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/codeeditor": "^4.6.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/rendermime": "^4.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/algorithm": "^2.0.4", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/messaging": "^2.0.4", + "@lumino/properties": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/documentsearch": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/documentsearch/-/documentsearch-4.6.3.tgz", + "integrity": "sha1-AV+CpUN8oEfOBlHperaAXDEG52k=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/commands": "^2.3.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/messaging": "^2.0.4", + "@lumino/polling": "^2.1.5", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/filebrowser": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/filebrowser/-/filebrowser-4.6.3.tgz", + "integrity": "sha1-+JH69gl1V2kbnP/XCTPy+eiWiY4=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/docmanager": "^4.6.3", + "@jupyterlab/docregistry": "^4.6.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/statedb": "^4.6.3", + "@jupyterlab/statusbar": "^4.6.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/algorithm": "^2.0.4", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/domutils": "^2.0.4", + "@lumino/dragdrop": "^2.1.8", + "@lumino/messaging": "^2.0.4", + "@lumino/polling": "^2.1.5", + "@lumino/signaling": "^2.1.5", + "@lumino/virtualdom": "^2.0.4", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/launcher": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/launcher/-/launcher-4.6.3.tgz", + "integrity": "sha1-X7LkKtDi+QI9MbNNmHMFWvzR4ZM=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/algorithm": "^2.0.4", + "@lumino/commands": "^2.3.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/properties": "^2.0.4", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/lsp": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/lsp/-/lsp-4.6.3.tgz", + "integrity": "sha1-EDpMTdS85AJiXWluYMWbQ6Ezjeg=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/codeeditor": "^4.6.3", + "@jupyterlab/codemirror": "^4.6.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/docregistry": "^4.6.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/translation": "^4.6.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0", + "lodash.mergewith": "^4.6.1", + "vscode-jsonrpc": "^8.2.0", + "vscode-languageserver-protocol": "^3.17.0", + "vscode-ws-jsonrpc": "~1.0.2" + } + }, + "node_modules/@jupyterlab/markedparser-extension": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/markedparser-extension/-/markedparser-extension-4.6.3.tgz", + "integrity": "sha1-Ab1Xmr13uuZZuXHeuwaW7U+5hls=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/application": "^4.6.3", + "@jupyterlab/codemirror": "^4.6.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/mermaid": "^4.6.3", + "@jupyterlab/rendermime": "^4.6.3", + "@lumino/coreutils": "^2.2.2", + "marked": "^17.0.6", + "marked-gfm-heading-id": "^4.1.4", + "marked-mangle": "^1.1.13" + } + }, + "node_modules/@jupyterlab/mermaid": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/mermaid/-/mermaid-4.6.3.tgz", + "integrity": "sha1-N/yXKoP0hnwDcKWaRNHGLZcgJP8=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/widgets": "^2.8.0", + "@mermaid-js/layout-elk": "^0.2.1", + "mermaid": "^11.15.0" + } + }, + "node_modules/@jupyterlab/nbformat": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/nbformat/-/nbformat-4.6.3.tgz", + "integrity": "sha1-MS6r0mxxHRU8xnQAeB3Jt6bazHs=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/coreutils": "^2.2.2" + } + }, + "node_modules/@jupyterlab/notebook": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/notebook/-/notebook-4.6.3.tgz", + "integrity": "sha1-V3qE8mehE7khSov+vcJvbW4mTO4=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyter/ydoc": "^4.0.0", + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/cells": "^4.6.3", + "@jupyterlab/codeeditor": "^4.6.3", + "@jupyterlab/codemirror": "^4.6.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/docregistry": "^4.6.3", + "@jupyterlab/documentsearch": "^4.6.3", + "@jupyterlab/lsp": "^4.6.3", + "@jupyterlab/markedparser-extension": "^4.6.3", + "@jupyterlab/nbformat": "^4.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/rendermime": "^4.6.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/settingregistry": "^4.6.3", + "@jupyterlab/statusbar": "^4.6.3", + "@jupyterlab/toc": "^6.6.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@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/messaging": "^2.0.4", + "@lumino/polling": "^2.1.5", + "@lumino/properties": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/virtualdom": "^2.0.4", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/observables": { + "version": "5.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/observables/-/observables-5.6.3.tgz", + "integrity": "sha1-CRV+46sW0si3C17kAoqeCtPyd14=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/algorithm": "^2.0.4", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/messaging": "^2.0.4", + "@lumino/signaling": "^2.1.5" + } + }, + "node_modules/@jupyterlab/outputarea": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/outputarea/-/outputarea-4.6.3.tgz", + "integrity": "sha1-kNhDrLB5kkFQO/Cbw8m+O/Xl09A=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/nbformat": "^4.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/rendermime": "^4.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/translation": "^4.6.3", + "@lumino/algorithm": "^2.0.4", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/messaging": "^2.0.4", + "@lumino/properties": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0" + } + }, + "node_modules/@jupyterlab/rendermime": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/rendermime/-/rendermime-4.6.3.tgz", + "integrity": "sha1-b0PSimtg9TjUtR9+Z3OWJ6E2E1s=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/nbformat": "^4.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/translation": "^4.6.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/messaging": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0", + "lodash.escape": "^4.0.1" + } + }, + "node_modules/@jupyterlab/rendermime-interfaces": { + "version": "3.14.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/rendermime-interfaces/-/rendermime-interfaces-3.14.3.tgz", + "integrity": "sha1-IMtFxAtBTwVawH+gWZVzFyWji78=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/coreutils": "^1.11.0 || ^2.2.2", + "@lumino/widgets": "^1.37.2 || ^2.8.0" + } + }, + "node_modules/@jupyterlab/services": { + "version": "7.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/services/-/services-7.6.3.tgz", + "integrity": "sha1-vpus+jeX2Wi5lbC3XEobU1IoAGM=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyter/ydoc": "^4.0.0", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/nbformat": "^4.6.3", + "@jupyterlab/settingregistry": "^4.6.3", + "@jupyterlab/statedb": "^4.6.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/polling": "^2.1.5", + "@lumino/properties": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "ws": "^8.11.0" + } + }, + "node_modules/@jupyterlab/settingregistry": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/settingregistry/-/settingregistry-4.6.3.tgz", + "integrity": "sha1-Mu7NldvROTVMoj+VJSJD9fpxSJ8=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/nbformat": "^4.6.3", + "@jupyterlab/statedb": "^4.6.3", + "@lumino/commands": "^2.3.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/signaling": "^2.1.5", + "@rjsf/utils": "^5.13.4", + "ajv": "^8.12.0", + "json5": "^2.2.3" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@jupyterlab/statedb": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/statedb/-/statedb-4.6.3.tgz", + "integrity": "sha1-1YXafvnlOcFSNkum+q8j+jtebFg=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/commands": "^2.3.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/properties": "^2.0.4", + "@lumino/signaling": "^2.1.5" + } + }, + "node_modules/@jupyterlab/statusbar": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/statusbar/-/statusbar-4.6.3.tgz", + "integrity": "sha1-QakFSJyVxF5iCFypDF22qPVP5CU=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/algorithm": "^2.0.4", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/messaging": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/toc": { + "version": "6.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/toc/-/toc-6.6.3.tgz", + "integrity": "sha1-sJhRAUp+AxsGF2s7j6PdSzP4KL0=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyter/react-components": "^0.16.6", + "@jupyterlab/apputils": "^4.7.3", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/docregistry": "^4.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/rendermime": "^4.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@jupyterlab/translation": "^4.6.3", + "@jupyterlab/ui-components": "^4.6.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/messaging": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/widgets": "^2.8.0", + "react": "^18.2.0" + } + }, + "node_modules/@jupyterlab/translation": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/translation/-/translation-4.6.3.tgz", + "integrity": "sha1-eUOKaywPvqH1fn4kxgUa8PYB16I=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@jupyterlab/services": "^7.6.3", + "@jupyterlab/statedb": "^4.6.3", + "@lumino/coreutils": "^2.2.2" + } + }, + "node_modules/@jupyterlab/ui-components": { + "version": "4.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jupyterlab/ui-components/-/ui-components-4.6.3.tgz", + "integrity": "sha1-NT5lPK0WU3rO11D1ZddgzE5w748=", + "license": "BSD-3-Clause", + "dependencies": { + "@jupyter/react-components": "^0.16.6", + "@jupyter/web-components": "^0.16.6", + "@jupyterlab/coreutils": "^6.6.3", + "@jupyterlab/observables": "^5.6.3", + "@jupyterlab/rendermime-interfaces": "^3.14.3", + "@jupyterlab/translation": "^4.6.3", + "@lumino/algorithm": "^2.0.4", + "@lumino/commands": "^2.3.3", + "@lumino/coreutils": "^2.2.2", + "@lumino/disposable": "^2.1.5", + "@lumino/messaging": "^2.0.4", + "@lumino/polling": "^2.1.5", + "@lumino/properties": "^2.0.4", + "@lumino/signaling": "^2.1.5", + "@lumino/virtualdom": "^2.0.4", + "@lumino/widgets": "^2.8.0", + "@rjsf/core": "^5.13.4", + "@rjsf/utils": "^5.13.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typestyle": "^2.0.4" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha1-1oQNsTd54/G0LnDJqXxAhtEvriI=", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/cpp/-/cpp-1.1.6.tgz", + "integrity": "sha1-RAjGbwzk+0d1mjuD2/3XgKSTFao=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/css/-/css-1.3.6.tgz", + "integrity": "sha1-LNrltTK+qlzx59zLkY2LGQtsbRQ=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/generator": { + "version": "1.8.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/generator/-/generator-1.8.0.tgz", + "integrity": "sha1-dZN9mlZFIYeWDMgHZQO9uBvKw1c=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.1.0", + "@lezer/lr": "^1.3.0" + }, + "bin": { + "lezer-generator": "src/lezer-generator.cjs" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha1-og8yS3EUii6pum/0Lli7+uxwKFc=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha1-ahMFrjvSycAfh3+KjcHhXsZS0Bw=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/java/-/java-1.1.4.tgz", + "integrity": "sha1-xXCIj6xCgzQf0rCmobi90BteuXw=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha1-EXRpVflX0zwJM/F9dZTbVKi0vuo=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha1-53OgEq0AiPvwfOSc+6h1zJ5bwF8=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha1-s6zDblrQSbdN23cZWU5+dNkWH/U=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.7.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/markdown/-/markdown-1.7.2.tgz", + "integrity": "sha1-3+AkmBPcj6pgtGWaTKK42m3K91M=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/php/-/php-1.0.6.tgz", + "integrity": "sha1-mltB90Becr7sfLPlEepKuRlt+yA=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha1-eEPUT/J8mAQ5qC6HwYsoFy6FxHg=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha1-zJp1YF1nGCoOeZrECxllph3MbvA=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha1-kIwgOSMoj4VOuOL02bBsQ36GELk=", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lumino/algorithm": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/algorithm/-/algorithm-2.0.5.tgz", + "integrity": "sha1-FHG0iaOjVIOLzwp86CixCypdBdc=", + "license": "BSD-3-Clause" + }, + "node_modules/@lumino/application": { + "version": "2.4.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/application/-/application-2.4.10.tgz", + "integrity": "sha1-Cs22xkPvQ1WqMffQDg098Ztq/fE=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/commands": "^2.3.4", + "@lumino/coreutils": "^2.2.3", + "@lumino/widgets": "^2.9.0" + } + }, + "node_modules/@lumino/collections": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/collections/-/collections-2.0.5.tgz", + "integrity": "sha1-2TnYEL/BgRCAkNwiGDVnMJ0jLfE=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/algorithm": "^2.0.5" + } + }, + "node_modules/@lumino/commands": { + "version": "2.3.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/commands/-/commands-2.3.4.tgz", + "integrity": "sha1-YkAVzbQpQnekdNqoRbN294QvHV4=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/algorithm": "^2.0.5", + "@lumino/coreutils": "^2.2.3", + "@lumino/disposable": "^2.1.6", + "@lumino/domutils": "^2.0.5", + "@lumino/keyboard": "^2.0.5", + "@lumino/signaling": "^2.1.6", + "@lumino/virtualdom": "^2.0.5" + } + }, + "node_modules/@lumino/coreutils": { + "version": "2.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/coreutils/-/coreutils-2.2.3.tgz", + "integrity": "sha1-gWicGzhtVGMPiahXeZWVYOTCK/A=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/algorithm": "^2.0.5" + } + }, + "node_modules/@lumino/disposable": { + "version": "2.1.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/disposable/-/disposable-2.1.6.tgz", + "integrity": "sha1-MvPRhK0A0coZlKgKmUk/7nZNQkk=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/signaling": "^2.1.6" + } + }, + "node_modules/@lumino/domutils": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/domutils/-/domutils-2.0.5.tgz", + "integrity": "sha1-xViX/2uXqI5Ach+m53NvazLj39E=", + "license": "BSD-3-Clause" + }, + "node_modules/@lumino/dragdrop": { + "version": "2.1.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/dragdrop/-/dragdrop-2.1.9.tgz", + "integrity": "sha1-iI+/Z3RpjfemraP7y8d/B8xh55I=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/coreutils": "^2.2.3", + "@lumino/disposable": "^2.1.6" + } + }, + "node_modules/@lumino/keyboard": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/keyboard/-/keyboard-2.0.5.tgz", + "integrity": "sha1-xAEPDkF4oUOd51xUcvSpj1O5koU=", + "license": "BSD-3-Clause" + }, + "node_modules/@lumino/messaging": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/messaging/-/messaging-2.0.5.tgz", + "integrity": "sha1-qVzvffGPScRdavKgiphvR5YS36g=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/algorithm": "^2.0.5", + "@lumino/collections": "^2.0.5" + } + }, + "node_modules/@lumino/polling": { + "version": "2.1.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/polling/-/polling-2.1.6.tgz", + "integrity": "sha1-vrxwOvNBAt69U65ukSfloWl7kh0=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/coreutils": "^2.2.3", + "@lumino/disposable": "^2.1.6", + "@lumino/signaling": "^2.1.6" + } + }, + "node_modules/@lumino/properties": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/properties/-/properties-2.0.5.tgz", + "integrity": "sha1-IRyi+6u/gxo35OA88JOMmBEpVKs=", + "license": "BSD-3-Clause" + }, + "node_modules/@lumino/signaling": { + "version": "2.1.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/signaling/-/signaling-2.1.6.tgz", + "integrity": "sha1-vfkJ2fbBplphDbq0h9R9Nl6dTj8=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/algorithm": "^2.0.5", + "@lumino/coreutils": "^2.2.3" + } + }, + "node_modules/@lumino/virtualdom": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/virtualdom/-/virtualdom-2.0.5.tgz", + "integrity": "sha1-jnnF/i8M//en892xxe4kS3ZRsB4=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/algorithm": "^2.0.5" + } + }, + "node_modules/@lumino/widgets": { + "version": "2.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@lumino/widgets/-/widgets-2.9.0.tgz", + "integrity": "sha1-EKCBA0UKYNRLTxV1Lp7pOK81oZg=", + "license": "BSD-3-Clause", + "dependencies": { + "@lumino/algorithm": "^2.0.5", + "@lumino/commands": "^2.3.4", + "@lumino/coreutils": "^2.2.3", + "@lumino/disposable": "^2.1.6", + "@lumino/domutils": "^2.0.5", + "@lumino/dragdrop": "^2.1.9", + "@lumino/keyboard": "^2.0.5", + "@lumino/messaging": "^2.0.5", + "@lumino/properties": "^2.0.5", + "@lumino/signaling": "^2.1.6", + "@lumino/virtualdom": "^2.0.5" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha1-QsKuphzaMHzbE0dER5JFLXtdv7Q=", + "license": "MIT" + }, + "node_modules/@mermaid-js/layout-elk": { + "version": "0.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@mermaid-js/layout-elk/-/layout-elk-0.2.3.tgz", + "integrity": "sha1-BlyZsEaCClRlcHDZH60VatEewPc=", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "elkjs": "^0.9.3" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha1-lMxAQWE3uxDSvBsKScYj1xbnYjM=", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/@microsoft/fast-colors": { + "version": "5.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/fast-colors/-/fast-colors-5.3.1.tgz", + "integrity": "sha1-3vxZh0F25CMWvn5tJMMYherYylY=", + "license": "MIT" + }, + "node_modules/@microsoft/fast-element": { + "version": "1.14.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/fast-element/-/fast-element-1.14.0.tgz", + "integrity": "sha1-ZSKxbVV4hkOwRBP6sCBeXpuk1ck=", + "license": "MIT" + }, + "node_modules/@microsoft/fast-foundation": { + "version": "2.50.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/fast-foundation/-/fast-foundation-2.50.0.tgz", + "integrity": "sha1-YGdlYd9c6LrQYOS3/refjc6VJDE=", + "license": "MIT", + "dependencies": { + "@microsoft/fast-element": "^1.14.0", + "@microsoft/fast-web-utilities": "^5.4.1", + "tabbable": "^5.2.0", + "tslib": "^1.13.0" + } + }, + "node_modules/@microsoft/fast-web-utilities": { + "version": "5.4.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/fast-web-utilities/-/fast-web-utilities-5.4.1.tgz", + "integrity": "sha1-jjCC7i/ytUZ/F+fLH7AbDkkGtx8=", + "license": "MIT", + "dependencies": { + "exenv-es6": "^1.1.1" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha1-p36nQvqyV3UUVDTrHSMoz1ATrDM=", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rjsf/core": { + "version": "5.24.13", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rjsf/core/-/core-5.24.13.tgz", + "integrity": "sha1-75jl3GrAZLK+L1bgiHyZ3ysfjUQ=", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "markdown-to-jsx": "^7.4.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@rjsf/utils": "^5.24.x", + "react": "^16.14.0 || >=17" + } + }, + "node_modules/@rjsf/utils": { + "version": "5.24.13", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rjsf/utils/-/utils-5.24.13.tgz", + "integrity": "sha1-sZykNL9RjsPmUsSgUQspP/X6wnk=", + "license": "Apache-2.0", + "dependencies": { + "json-schema-merge-allof": "^0.8.1", + "jsonpointer": "^5.0.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-is": "^18.2.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.14.0 || >=17" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha1-1FUKhdCPSXj68KTDa4SMYeqsB+I=", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha1-4CFRRk0C1KG0RkbQ/NuT+viP3ow=", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha1-52DldluBiLHe+jK8i7YGL4Hkx5U=", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha1-wvQ2KwRdRy4bGGzb7DKbpSva7mw=", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha1-FwbKQM9+pZoK3Y9EVu//j4d1eT0=", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha1-NoyWGhjech2oIA6AvzlD+1MTavI=", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha1-mto/qcTQDjpQk/7QNWx6uSlgQjE=", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha1-GFwagMyAf92io/6WD3wRxKJ5UuE=", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha1-7wBNihKARs/OQ00XGC+DTkTvlbI=", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha1-sTq6iyRCtAaMmp5tHYL4vOp3/AI=", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha1-CjUfmW3Jmzf0+li0ksLRwE49rBc=", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha1-4o2xv7+mFwdvd3DdHZpI6qO2xRs=", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha1-wEorTyMYGqN28wrwKD28eztWmYA=", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha1-bcj8bh81cE87BXCQvu63rGdL/xo=", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha1-seRGVkTds/3zomP+uyQKbNYW3pA=", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha1-nig68XlgHFSVgWALP+wllBkRMp0=", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha1-YCP7Oy1GMiny1oD5rEtHRm9x8Xs=", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha1-QSuQ6EhwKF8v+KhGxutgNE8SpBw=", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha1-9jKzgMOsoduo40qgSbzWpK8j34o=", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha1-365UptNdGedqyVZbyzKo5UaTGJw=", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha1-1HQLD+NbHFi2bhSI9OftApUvVw8=", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha1-a9NoO4My/A8B5wWbdja8XH7eczc=", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha1-V6L3ByQub+Hega17/Myq9gYXmvs=", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha1-3G1Pmpg3bxjqULrWw5U38bVGPDk=", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha1-vXpF/AqMMWemMWdeYbwsorBY1KM=", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha1-Zv80IBHcJDxsIOa4mVI9FIrKQS0=", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha1-hHL+7NY5aRRQ3YAA6zPt1EThMj8=", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha1-1rwea2p9tpzM+73Uw0twYy2enbI=", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha1-cLvad9wjqnJ0E+IuIUr6Pw6FL3A=", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha1-ETa8V+nds8OQ3MybX/O30rjZRwY=", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha1-3Msy0cVrHhxuDxGA2ZSJbwOLxAs=", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha1-zz8Oh2177hWpOrkluCv1cKOQSiQ=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha1-jr5T1p762nBERU4zBcGQF9l87So=", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha1-WWoXRyM2lNUPatinhp/Lb1bPWEE=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.5.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-26.5.1.tgz", + "integrity": "sha1-sZw5DhWBP0AqlLhuOvkEL3khOL4=", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha1-5uWobWAr6spxzlFj+t9fldcJMcc=", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react/-/react-18.3.31.tgz", + "integrity": "sha1-teleKP/M6rjZgvM/LrB24XZTwqQ=", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha1-uJ3fLNg7T+r8xOLqQa/fuVoNGU8=", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha1-usywepcLkXB986PoumiWxX6tLRE=", + "license": "MIT", + "optional": true + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha1-O+GSA4zdqSeqT4siq1Gvgqv0fzQ=", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha1-qfagfysDyVyNOMRTah/ftSH/VbY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha1-/Moe7dscxOe27tT8eVbWgTshufs=", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha1-4KFhUiSLw42u523X4h8Vxe86sec=", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha1-giqbxgMWZTH31d+E5ntb+ZtyuWs=", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha1-29kyVI5xGfS4p4d/1ajSDmNJCy0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha1-5VYQh1j0SKroTIUOWTzhig6zHgs=", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha1-lindqcRDDqtUtZEFPW3G87oFA0g=", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha1-HF6qzh1gatosf9cEXqk1bFnuDbo=", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha1-V8XD3rAQXQLOJfo/109OvJ/Qu7A=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha1-kXog6T9xrVYClmwtaFrgxsIfYPE=", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha1-rGaJ9QIhm1kZjd7ELc1JaxAE1Zc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha1-mR5/DAkMsLtiu6yIIHbj0hnalXA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha1-5vce18yuRngcIGAX08FMUO+oEGs=", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha1-s+E/GJNgXKeLUsaOVM9qhl+Qufs=", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha1-O7PpY4qK5f2vlhDnoGtNn5qm/gc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha1-Oy+FLpHaxuO4X7KjFPuL70bZRkY=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha1-zD+/Iu/riP9iMQz4hcWwn0SuD90=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha1-Ml20I5XNSf5sFAV/mpAOQn34gQ4=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha1-7vAUoxRa5Hehy8AM0eVSM23Ot5A=", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha1-0pHGpOl5ibXGHZrPOWrk/hM6cY0=", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/abab/-/abab-2.0.6.tgz", + "integrity": "sha1-QbgPLIcdGWhiFrgjCSMc/Tyz0pE=", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha1-T68BstbTJr/u2XrqH1IiC19MGUA=", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha1-MEs2Nq3Yi6fZNnYN1Q7OAG3qlfk=", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha1-PV3HYryhdnnDwup+kK1rdTIwlXg=", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha1-adTThaRzPNvqtElkoRcKiPh/DhY=", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha1-JHyOe3ChpDsQzhTAIm/L9Y6IFdU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha1-QfuyAkPlCxK+DwS43tvwdSDOhB0=", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha1-6D46fj8wCzTLnYf2FfoMvzV2kO4=", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.23", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha1-ZfCvOC9Xi83HQr2cKB6cstd2gyg=", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha1-B85rRJuQr4gOub+3zTk3LMT3HIw=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha1-KxRqb9cugLT1XSVfNe1Zo6mkG9U=", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha1-SXC0d96jJ4N03pvEOqj105/DzaI=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha1-zUJUFnelQzPPVBpJEIwUMrRMlCQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha1-4uaaRKyHcveKHsCzW2id9lMO/I8=", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha1-Bb/9f/koRlCTMUcIyTvfqb0fD1s=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha1-wZ/Zvbv4WUK0/ZechNz31fB8I4c=", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clone-deep/node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha1-LBY7P6+xtgbZ0Xko8FwqHDjgdnc=", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha1-nreT5oMwZ/cjWQL807CZF6AAqVo=", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-9.5.0.tgz", + "integrity": "sha1-vAjR61zt98y3l6lhmdQce8PmDTA=", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/compute-gcd": { + "version": "1.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/compute-gcd/-/compute-gcd-1.2.1.tgz", + "integrity": "sha1-NNY584JWJeE1fOgfDkVqYknYx38=", + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, + "node_modules/compute-lcm": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/compute-lcm/-/compute-lcm-1.1.2.tgz", + "integrity": "sha1-kQfGa53KKM77IrSrRUXKrEA0ryM=", + "dependencies": { + "compute-gcd": "^1.2.1", + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true, + "license": "MIT" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha1-ZQM0tBuGlXilQzWLgM2n4Kvgpgo=", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha1-O0QbLd+nMWHWoncKpM1nf4leryg=", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha1-ilj+ePANzXDDcEUXWd+/rwPo7p8=", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "6.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/css-loader/-/css-loader-6.11.0.tgz", + "integrity": "sha1-M7rjv2Nj0KfCz5AxyWx0T/VNhbo=", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha1-N3QZGZA7hoVl4cCep0dEXNGJg+4=", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha1-7EjA8+mT5QZIyG2lWeJhCZXPmJo=", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha1-FQOZa6C1m5AdhjEKH2EuktxGT1E=", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha1-di+hId+ZMP/rUaSV2HkXxXCsIJs=", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha1-5Nb2SQ30+rWK6c6p5cOrjXRy9HE=", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha1-HDlcNbbhC7g/l2nKi4F9YUrdXAE=", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha1-0DN5E1hskPnCwHUpIGn1wtpd0oU=", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3/-/d3-7.9.0.tgz", + "integrity": "sha1-V556yz10nK+IYL0XQa6NNxBwzV0=", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha1-Ff7DOyN/l6xdfJhtx32ic6jtC7U=", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha1-xCpKE+gTHWN7dF/Clzgkz+r5MyI=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha1-b3Z8Ttjct53n7ePhwPieY+9k0xw=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha1-0VbWH0hfzoMn5qvzOctB2Mu6aWY=", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha1-OVsoM9+scVB/EqwvevI7+BneJOI=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha1-u5IGO8jFZjrLJCL5nHPLtsauO8w=", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha1-mBaQOHM6ClurvtpVBU95W7nkpYs=", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha1-X8dShOnCN1w2yDlBGgz1UMv8TV4=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha1-mUqunNI8cZ9TteEOOgphCMaWB7o=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha1-xjr5ePTWoNCEpSpnOSK+IWB4m3M=", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-7.2.0.tgz", + "integrity": "sha1-o2y1fQtQHOEI5NIFWaFQo5HZerc=", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha1-llisOKIUDVnTRhYPH2ww/aC9EvQ=", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha1-gxQb/5hWoO21443onNz+Y9CmCiI=", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha1-Piuhph5wiI/j2RlOMNbRTuzhVcQ=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha1-Af20a1i+sfVbELQq1wtuNE1esq4=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha1-YCfPUSRvmy69ZPmeAdx8M2QDOk0=", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha1-sBzULB7tPUbbd6WWbPcm+MCRYMY=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha1-PEeqWzLFs9+1bvP9Q0IHimMrQA0=", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha1-It+TkDL7WnGuixgA1h3beFHEJSY=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha1-C0XT3RxIopyOBX5hNWk+yAvxY5g=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha1-bco+i+Kzk8mp1RTau9gKkt7vGk8=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha1-1JJjeNMz2cC/0eb6AZTTCuuqIPQ=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha1-s8JoYnvXLl2AM26N5qy/7J0V0B0=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha1-4gtBqvzf/fXVCSgATs7PgVpGXoE=", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha1-SMBQux/owmJJOoyvVSTj6VkXAc8=", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha1-32OAG+B7yYa8VPY3ibT+UCmStdc=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha1-ABfMijuZYF8DAvKxmNJy4BXl35U=", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha1-grOOjo/3CAdk+Nzsd71L45Nok5Y=", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha1-NMOdopiyPCDgLxpLI5vQ8i5/ExQ=", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha1-wlM4IH76csxbm9FFihpBkB8eGzE=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha1-oag5y9m6RfKGdMadf4Vbz5HfxqU=", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha1-kxDbVumS48AXXh7zheVF5Iqbtcc=", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha1-erUlelBB0R7LT+cKXH0WoZW7QIo=", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha1-YoTSonCChbGrt+IB7aQ4CvNeY7A=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha1-aGn93hRIhoB3/dWYkgDLYbKhZF8=", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha1-0T9BZccyF//qpUKVzWlps+eu6PM=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha1-EnInbiZFfPO5faxWn48FMewzw3c=", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/data-urls": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha1-FWSFpyljqXD11YIar2Qr7yvy25s=", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha1-sKNjUG3eXzbPUHXkLr6BFRZajHk=", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha1-RLXyFHzTsA1LVhN2hZZvJv0l3Uo=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha1-0TJx+/Ov9nU/nqbiNVV/IJAQRuo=", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha1-5BuALh7t+fbK4YPOXmIteJ19jlM=", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha1-XEXo6GmVJiYzHXqrMm0B2vZdWJ0=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha1-zDhff3UfHR/GUMITdIBCVFOMfTE=", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha1-MDUbNFE4lPQowTvdsov/CgJ66AQ=", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha1-7b/itmiwwdl8JLrw8QYrEyIhvHg=", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/duplicate-package-checker-webpack-plugin": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/duplicate-package-checker-webpack-plugin/-/duplicate-package-checker-webpack-plugin-3.0.0.tgz", + "integrity": "sha1-eLuJ5iX6fPjCpZxT9itJX9qbooc=", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.3.0", + "find-root": "^1.0.0", + "lodash": "^4.17.4", + "semver": "^5.4.1" + } + }, + "node_modules/duplicate-package-checker-webpack-plugin/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/semver/-/semver-5.7.2.tgz", + "integrity": "sha1-SNVdtzfDKHzUg14X+hP+rOHEHvg=", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha1-aWzi7Aqg5uqTo5f/zySqeEDIJ8s=", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha1-FnEfjOsJ8bErmelxsTioOEpSkWE=", + "license": "EPL-2.0" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha1-hAyIA7DYBH9P8M+WMXazLU7z7XI=", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha1-VXBmIEatKeLpFucariYKvf9Pang=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/entities/-/entities-4.5.0.tgz", + "integrity": "sha1-XSaOpecRPsdMTQM7eepaNaSI+0g=", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha1-BKJRvnn5JUhUHzfRPItvIpQMO64=", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha1-BfdaJdq5jk+x3NXhRywFRtUFfI8=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha1-MR+k9AFowZdcUFR3xRsjI01BrVU=", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha1-cerxqLGINO93Y37Mu4hbpMA81t0=", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha1-ARo/aYVroYnf+n3I/M6Z0qh5A+U=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/events/-/events-3.3.0.tgz", + "integrity": "sha1-Mala0Kkk4tLEGagTrrLE6HjqdAA=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exenv-es6": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/exenv-es6/-/exenv-es6-1.1.1.tgz", + "integrity": "sha1-gLeoxa8k1TMx91W6wH6Eq7H23mc=", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha1-On1WtVnWy8PrUSMlJE5hmmXGxSU=", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha1-h0v2nG9ATCtdmcSBNBOZ/VWJJjM=", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha1-dDFX2Vfzy7TGUxDgM9wq1K19xgo=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha1-rkPVWvAXJSrkmbLhhlEauXQS3jk=", + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha1-IQ5htv8YHekeqbPRuE/e3UfgNOU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha1-q8/Iunb3CMQql7PWhbfpRQv7nOQ=", + "dev": true, + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha1-l6/n1s3AvFkoWEt8jXsW6KmqXRk=", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/flat/-/flat-5.0.2.tgz", + "integrity": "sha1-jKb+MyBp/6nTJMMnGYxZglnOskE=", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha1-Mujp7Rtoo0l777msK2rfkqY4V28=", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/free-style": { + "version": "3.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/free-style/-/free-style-3.1.0.tgz", + "integrity": "sha1-TimWApU05rFzFhHYQ0N7ni9HPwg=", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha1-Aoc8+8QITd4SfqpfmQXu8jJdGr8=", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha1-LALYZNl/PqbIgwxGTL0Rq26rehw=", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha1-Us8vknmiHrbFndOFtBDwwK3ajxo=", + "license": "ISC" + }, + "node_modules/glob": { + "version": "7.1.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-7.1.7.tgz", + "integrity": "sha1-Oxk+kjPwHULQs/eClLvutBj5SpA=", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha1-QYPk6L8Iu24Fu7L30uDI9xLKQOM=", + "dev": true, + "license": "ISC" + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha1-0ZvEzIdQpZYrR/sTAFV6hfz5NMw=", + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha1-lEdx/ZyByBJlxNaUGGDaBrtZR5s=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha1-jGLYy5C+sqrV0KW2dYGtmFTD8AM=", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha1-8AIVFwWzg+YkM7XPRm9bcW7a7CE=", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha1-pS+AvzjaGVLrXGgXkHGYcaGnJQE=", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha1-xr5oWKvQE9do6YNmrkfiXViHsa4=", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha1-w9XHRXmMAqb4uJdyarpRABhu4mA=", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha1-CMuFtb037MjrHg9nDcJ2cALUNzQ=", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=", + "dev": true, + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha1-ZoXyN1XkPFJOJR0py8lySOMGEAk=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha1-W+DO7WfKecbEvFzw1+6EPc6hEMQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha1-PgdFCoCA684/vwysSU9NKrMk4II=", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha1-Iu17XoVrtXlmNI5blGja2kK+h/Q=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha1-E+7PNvLbpT6F01XhG/nUIIxvf4g=", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha1-iDOp2Jq0rN5hiJQr0cU7Y5DtWoo=", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha1-jRRvCQDolzsQa29zzB6ajLhvjbA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha1-zW/BfihQDP9WwbhsCn/UpUpzAFw=", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha1-GSA/tZmR35jjoocFDUZHzerzJJk=", + "license": "MIT" + }, + "node_modules/json-schema-compare": { + "version": "0.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-compare/-/json-schema-compare-0.2.2.tgz", + "integrity": "sha1-3WAVCDNakMf0z622suOXIlyQjlY=", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.4" + } + }, + "node_modules/json-schema-merge-allof": { + "version": "0.8.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz", + "integrity": "sha1-7SgozdlYYW/3T5MoMKJikXieqvI=", + "license": "MIT", + "dependencies": { + "compute-lcm": "^1.1.2", + "json-schema-compare": "^0.2.2", + "lodash": "^4.17.20" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha1-rnvLNlard6c7pcSb9lTzjmtoYOI=", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json5/-/json5-2.2.3.tgz", + "integrity": "sha1-eM1vGhm9wStz21rQxh79ZsHikoM=", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha1-tuMXF/Isw3MwsIHOAFHtXeU68vY=", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha1-IRDgrwkA/TdGe1kH7NE6eIShtVk=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/katex/-/katex-0.16.47.tgz", + "integrity": "sha1-ChOkLC3rT3TmHxYtRAuRZaVIAw8=", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-8.3.0.tgz", + "integrity": "sha1-SDfqGy2me5xhamevuw+v7lZ7ymY=", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha1-RfLOlM4jGkN89bY8LohubrQru7E=" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha1-B8BQNKbDSfoG4k+jWqdttFgM5N0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha1-EpHilog8Miqd1MXdggY3IbU+JuI=", + "license": "MIT" + }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha1-bD+SZHXSiQSvBbWQcDy7vClHVxY=", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/license-webpack-plugin": { + "version": "4.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", + "integrity": "sha1-HhhELtILdUuC8a3v9CJJuB0RrsY=", + "dev": true, + "license": "ISC", + "dependencies": { + "webpack-sources": "^3.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-sources": { + "optional": true + } + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha1-i1yzi1w0qaAY7h/A5qBm0d/MUow=", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha1-Gvujlq/WdqbUJQTQpno6frn2KqA=", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha1-/ytmwfYybVlRPeJAe/iBQ5gSdxw=", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha1-uWLuuA2dmDqQC/NClh+3QYyhCx0=", + "license": "MIT" + }, + "node_modules/lodash.escape": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg=", + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha1-YXEh+JrFX1kEfHrsHM1mVMZZD1U=", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha1-ce5R+nvkyuwaY4OffmgtgTLTDK8=", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha1-QQ/IoXtw5ZgBPfJXwkRrfzOD8Rk=", + "dev": true, + "license": "ISC" + }, + "node_modules/markdown-to-jsx": { + "version": "7.7.17", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz", + "integrity": "sha1-bpl9aqTb4uacQjxldFVBhGd3SDw=", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "react": ">= 0.14.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/marked/-/marked-17.0.6.tgz", + "integrity": "sha1-KpdYaictO+WIDxmOAgt0rSfPhro=", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marked-gfm-heading-id": { + "version": "4.1.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/marked-gfm-heading-id/-/marked-gfm-heading-id-4.1.4.tgz", + "integrity": "sha1-nw7nus41zpyQxYcAWT1s27RhhwY=", + "license": "MIT", + "dependencies": { + "github-slugger": "^2.0.0" + }, + "peerDependencies": { + "marked": ">=13 <19" + } + }, + "node_modules/marked-mangle": { + "version": "1.1.13", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/marked-mangle/-/marked-mangle-1.1.13.tgz", + "integrity": "sha1-KxGUwayMXiIm1u8yFusA6UlL2MA=", + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <19" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha1-UoI2KaFN0AyXcPtq1H3GMQ8sH2A=", + "dev": true, + "license": "MIT" + }, + "node_modules/mermaid": { + "version": "11.17.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha1-48rzcXWCwOROXQTP8EPgJcrsfLo=", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "fastdom": "1.0.12", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/marked/-/marked-16.4.2.tgz", + "integrity": "sha1-SVmmS+bEhvDbdGfq184ojeVCkKM=", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha1-zds+5PnGRTDf9kAjZmHULLajFPU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.10.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.2.tgz", + "integrity": "sha1-XIXslFDAXSbjJTG0ZaFaCMOlclM=", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha1-irCqvN+MKa1Wk8pZWvGd0urQmTk=", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha1-WAyI+NVEXyvWqo88re+g3nn71p4=", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha1-waRk52kzAuCCoHXO4MBXdBrEdyw=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.10.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.10.1.tgz", + "integrity": "sha1-MzrSelP81Ru9hoJSpHM+4ISAsCE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.3", + "terser": "^5.51.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@napi-rs/image": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "imagemin": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "sharp": { + "optional": true + }, + "svgo": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha1-eTibTrG7LQA6m7qH1JLyvTe9xls=", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha1-tKr7k+OustgXTKU88WOrfXMIMF8=", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha1-xS+u22gAHc/ndJW17RrFgnoXiPw=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha1-PdM8ZHohT9//2DWTPrCG2g3CHbE=", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha1-o0KLtwiLOmApL2aRkni3wpetTwc=", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha1-yyhoVA4xPWHeWPr741zpAE1VQOY=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha1-TxRxoBCCeob5TP2bByfjbSZ95QU=", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha1-cMmixL0aUT3NbK0Aap/OvsIqElM=", + "license": "MIT" + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha1-2YRUqcN1PVeQhg8W9ohnueRr4f0=", + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha1-j1ulzHD8e+yz3O+uoI4mWaumC4w=", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha1-UTvb4tO5XXdi6METfvoZXGxhtbM=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha1-+8EUtgykKzDZ2vWFjkvWi77bZzU=", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha1-eWCmaIiFlKByCxKpEdGnQqufEdI=", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", + "license": "ISC" + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha1-8JkTPfft5CLoHR2ESCcO6z5CYfM=", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha1-fbuYxDeRhZQ0KEdhMw+ok8uBtNE=", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha1-VTICtUJMU77TcTWzGIWOrP+F3VI=", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha1-2kVjqZoG5i1sHNGsrjYyJLyu1uk=", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha1-tEl8uFqcDEtaq+t1m7JejYnxUAI=", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha1-0VD0ODeDHa4l5AhVluhPb11uw2g=", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha1-G7zN3LOY8delEeCi0dBHcYr0B4w=", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha1-18Xn5ow7s8myfL9Iyguz/7RgLJw=", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha1-Relw8tk9RUfWdUFn7REy9DASxL8=", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha1-cjwJkgg2um0+WvAZ+SvAlxwC5RQ=", + "dev": true, + "license": "MIT" + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha1-Z9h78aaU9IQ1zzMsJK8QIUoxQLU=", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha1-eJcppNw23imZ3BVt1sHZwYzqVqQ=", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha1-AnQi4vrsCyXhVJw+G9gwm5EztuU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha1-M0WUG0FTy50ILY7uTNogFqmu9/Y=", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react/-/react-18.3.1.tgz", + "integrity": "sha1-SauJIAnFOTNiW9FrJTP8dUyrKJE=", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha1-wiZdeVEbV9R5s90/36UVNklMXLQ=", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha1-6DVX3BLq5jqZ4AOkY4ix3LtE234=", + "license": "MIT" + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha1-Sfhm4NMhRhQto62PDv81KzIV/yI=", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha1-iaf92TgmEmcxjq/hT5wy5ZjDaQk=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha1-9bKmgIl8acI4oTzRaxVnH4tzVJ8=", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha1-DwB18bslRHZs9zumpuKt/ryxPy0=", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha1-w1IlhD3493bfIcV1V7wIfp39/Gk=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha1-I7mEPT3JLbcfluGizpLjn9KoIhw=", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha1-WJ2rEcABjQNmvmTNi/Esjb7MgyY=", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-10.5.0.tgz", + "integrity": "sha1-jsA1WRnNMzjChCiiPU8k7MX+c4w=", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha1-mwy5/LeAh/b9fqur4lEcTT1gV04=", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha1-EJkGGzNJ4sWr7GwqsKzUQNJNQGI=", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha1-EFn0ml4MgN7lQaAFsgzDIrIiFYs=", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rw/-/rw-1.3.3.tgz", + "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha1-RPoWGwGHuVSd2Eu5GAL5vYOFzWo=", + "license": "MIT" + }, + "node_modules/sanitize-html": { + "version": "2.12.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sanitize-html/-/sanitize-html-2.12.1.tgz", + "integrity": "sha1-KAoPXDcwUiKSH2+dYFvh9lWJFMc=", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha1-FLqDpdNz49MR5a/KKc9b+tllvzQ=", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha1-QUumSjsoKJLpRM8hCOzAeNEVzcM=", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/semver/-/semver-7.8.5.tgz", + "integrity": "sha1-ObZGA33VDBT7RR5+TKxY7YuGP2k=", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha1-jymBrZJTH1UDWwH7IwdppA4C76M=", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo=", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha1-lSGIwcvVRgcOLdIND0HArgUwywQ=", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha1-dHIq8y6WFOnCh6jQu95IteLxomM=", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-loader/-/source-map-loader-1.0.2.tgz", + "integrity": "sha1-sKZYKy6qOH7eHs+AYa4Lk8I/nrA=", + "dev": true, + "license": "MIT", + "dependencies": { + "data-urls": "^2.0.0", + "iconv-lite": "^0.6.2", + "loader-utils": "^2.0.0", + "schema-utils": "^2.7.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/source-map-loader/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha1-B+mCx0YmFnqnoklcU4F4ktcTlJI=", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/source-map-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha1-MfKdpatuANHC0yms97WSlhTVAU0=", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/source-map-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha1-afaofZUTq4u4/mO9sJecRI5oRmA=", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-loader/node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha1-HKTzLRskxZDCA7jnpQvw6kzTlNc=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha1-BP58f54e0tZiIzwoyys1ufY/bk8=", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha1-GJ3pFkn3PUTVm4Qy76aO+dJllGA=", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha1-FPja7G2B5yIdKjV+Zoyrc728p5Q=", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/style-loader": { + "version": "3.3.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/style-loader/-/style-loader-3.3.4.tgz", + "integrity": "sha1-8w94bDbbA6RcvVW2pw2TDEeQkOc=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha1-bpASJVu3mb2sN+KI92cbXXG/n3M=", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha1-xYRsk0X0v8Ub0MvXyjWgdE9IWl0=", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha1-G33NyzK4E4gBs+R4umpRyqiWSNo=", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha1-btpL00SjyUrqN21MwxvHcxEDngk=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "5.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tabbable/-/tabbable-5.3.3.tgz", + "integrity": "sha1-qsD/iMc7ItbDxaULFYYxAAa0f78=", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha1-XafJmSxGA4IhJnmFqyhCGoh58WA=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/terser/-/terser-5.51.2.tgz", + "integrity": "sha1-VVkSQlp/L/dzdCXDcY+nbHBp9u8=", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha1-R7xBvYuPq4ODti7HY7c5SCkJfns=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-2.20.3.tgz", + "integrity": "sha1-/UhehMA+tIgcIHIrpIA16FMa6zM=", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha1-FqLjxuI/r85yZA5njmUds2FFucY=", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tr46": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha1-+oeqgcpdWUHajL8fm3SdyWmk4kA=", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha1-j6w2x5ArVBwVSsE6J6xGeZevEfg=", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha1-zy04vcNKE0vK8QkcQfZhni9nLQA=", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha1-s85roljnLmMFumb1ybRSqu4//jc=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typestyle": { + "version": "2.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typestyle/-/typestyle-2.4.0.tgz", + "integrity": "sha1-31uub/FQk/XOUfDKrF73lCj2Tng=", + "license": "MIT", + "dependencies": { + "csstype": "3.0.10", + "free-style": "3.1.0" + } + }, + "node_modules/typestyle/node_modules/csstype": { + "version": "3.0.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/csstype/-/csstype-3.0.10.tgz", + "integrity": "sha1-KtOnvtcPNbllcHwJLl8wsyfCkOU=", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha1-4kDZfItdheU0fOc9JYZceQbB7J8=", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha1-Fo78IYCWTmOG0GHglN9hr+I5sY0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha1-mxpSWVIlhZ5V9mnZKPiMbFfyp34=", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha1-nTwvc2wddd070r5QfcwRHx4uqcE=", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha1-1a4D5NsIgchycfiwub1zEtAseZo=", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/validate.io-array": { + "version": "1.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/validate.io-array/-/validate.io-array-1.0.6.tgz", + "integrity": "sha1-W1osr9j4uFq7L4hroVPy2Tond00=", + "license": "MIT" + }, + "node_modules/validate.io-function": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/validate.io-function/-/validate.io-function-1.0.2.tgz", + "integrity": "sha1-NDoZgC7TsZaCaceA5VjpNBHAutc=" + }, + "node_modules/validate.io-integer": { + "version": "1.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/validate.io-integer/-/validate.io-integer-1.0.5.tgz", + "integrity": "sha1-FoSWSAuVviJH7EQ/IjPeT4mHgGg=", + "dependencies": { + "validate.io-number": "^1.0.3" + } + }, + "node_modules/validate.io-integer-array": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/validate.io-integer-array/-/validate.io-integer-array-1.0.0.tgz", + "integrity": "sha1-LKveAzKTpry+Bj/q/pHq9GsToIk=", + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-integer": "^1.0.4" + } + }, + "node_modules/validate.io-number": { + "version": "1.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/validate.io-number/-/validate.io-number-1.0.3.tgz", + "integrity": "sha1-9j/+2iSL8opnqNSODjtGGhZluvg=" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha1-oyLMDx2X95T/2cTNKomKC94JfzQ=", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz", + "integrity": "sha1-Y5OggGD7lPVSkkP6O+BWxnY5ghE=", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.2", + "vscode-languageserver-types": "3.18.3" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { + "version": "9.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz", + "integrity": "sha1-CGt2xPlPGwdD3urE4LV8nEpV6B0=", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", + "integrity": "sha1-6ylsQv1lk3WoKVG5zDPnZyup7+c=", + "license": "MIT" + }, + "node_modules/vscode-ws-jsonrpc": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vscode-ws-jsonrpc/-/vscode-ws-jsonrpc-1.0.2.tgz", + "integrity": "sha1-6tLv1mKT8zHMwiAiKuGuyku1ssE=", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "^8.0.2" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha1-exfIxog9TouGrIq6edOeiA+IacU=", + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha1-4S6C2EZ0Jm/Bxtv+OIkbkv8FIuw=", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "6.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha1-kRG01+qArNQPUnDWZmIa+ni2lRQ=", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10.4" + } + }, + "node_modules/webpack": { + "version": "5.110.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webpack/-/webpack-5.110.3.tgz", + "integrity": "sha1-4SK2b2Imt6+PIJthAs0TI4xIE6g=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.7.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha1-yOBGun6q5JEdfnHislt3b8w1dZs=", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-10.0.1.tgz", + "integrity": "sha1-iB7ka0930cHczFgjQzqjmwIsvgY=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha1-o61ddzJB6caCgDq/Yo1M1iuKQXc=", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha1-dsJBhIbcwCsqoGlMEEF2woWP6Eo=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha1-PUseAxLSB5h5+Cav8Y2+7KWWD78=", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha1-ZWp45RD/jzk3vAvL6fXArDWUG3c=", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", + "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha1-WrENAkhxmJVINrY0n3T/+WHhD2c=", + "dev": true, + "license": "MIT" + }, + "node_modules/worker-loader": { + "version": "3.0.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/worker-loader/-/worker-loader-3.0.8.tgz", + "integrity": "sha1-X8XNpKPTFj2cJ0pOOoEc6LYNuzc=", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/worker-loader/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha1-B+mCx0YmFnqnoklcU4F4ktcTlJI=", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/worker-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha1-MfKdpatuANHC0yms97WSlhTVAU0=", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/worker-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha1-afaofZUTq4u4/mO9sJecRI5oRmA=", + "dev": true, + "license": "MIT" + }, + "node_modules/worker-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha1-9QqIh3w8AWUqFbYirp6Xld96YP4=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha1-VtwiNo7lcPrOG0mBmXXZuaXq0hQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ws/-/ws-8.21.3.tgz", + "integrity": "sha1-ZgtPrdtqPldchuB4EmkZlh9N5Pw=", + "license": "MIT", + "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 + } + } + }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha1-ZjHEkudbeLOmE1OmAGfm+KTDjV8=", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/yjs": { + "version": "13.6.32", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yjs/-/yjs-13.6.32.tgz", + "integrity": "sha1-hZ3KSNeyZlxvS3pEgel7cqsp57I=", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + } + } +} diff --git a/sdk/python/python/labextension/package.json b/sdk/python/python/labextension/package.json new file mode 100644 index 00000000..b3292a56 --- /dev/null +++ b/sdk/python/python/labextension/package.json @@ -0,0 +1,41 @@ +{ + "name": "taugrid-jupyterlab", + "version": "0.1.0", + "description": "Native TauGrid RayJob status console for JupyterLab, with notebook submission gated.", + "keywords": ["jupyter", "jupyterlab", "jupyterlab-extension", "taugrid", "ray", "kubernetes"], + "license": "MIT", + "author": "TauGrid maintainers", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "style": "style/index.css", + "files": ["lib/**/*.{js,js.map,d.ts,d.ts.map}", "style/**/*.{css,js,js.map}"], + "scripts": { + "build": "npm run build:lib && npm run build:labextension", + "build:labextension": "node tools/build-labextension.cjs", + "build:lib": "tsc", + "clean": "rimraf lib tsconfig.tsbuildinfo", + "test": "node --test tests/console.test.cjs" + }, + "dependencies": { + "@jupyterlab/application": "^4.0.0", + "@jupyterlab/apputils": "^4.0.0", + "@jupyterlab/coreutils": "^6.0.0", + "@jupyterlab/launcher": "^4.0.0", + "@jupyterlab/notebook": "^4.0.0", + "@jupyterlab/services": "^7.0.0", + "@lumino/widgets": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@jupyterlab/builder": "^4.0.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "rimraf": "^5.0.0", + "typescript": "~5.3.0" + }, + "jupyterlab": { + "extension": true, + "outputDir": "../tau/labextension" + } +} diff --git a/sdk/python/python/labextension/src/api.ts b/sdk/python/python/labextension/src/api.ts new file mode 100644 index 00000000..32b6dbc4 --- /dev/null +++ b/sdk/python/python/labextension/src/api.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { URLExt } from '@jupyterlab/coreutils'; +import { ServerConnection } from '@jupyterlab/services'; +import { RunTarget } from './model'; + +export type ApiQuery = Partial & { queue?: string; pod?: string; container?: string; tail?: string; previous?: string; timestamps?: string; includeMetrics?: string; path?: string }; + +export function apiUrl(path: string, target?: ApiQuery): string { + const settings = ServerConnection.makeSettings(); + const query = target ? `?${new URLSearchParams(Object.entries(target).filter(([, value]) => value !== undefined) as [string, string][])}` : ''; + return URLExt.join(settings.baseUrl, 'taugrid', 'api', path) + query; +} + +async function request(path: string, init: RequestInit, target?: ApiQuery): Promise { + const settings = ServerConnection.makeSettings(); + const controller = new AbortController(); + const abort = (): void => controller.abort(); + init.signal?.addEventListener('abort', abort, { once: true }); + if (init.signal?.aborted) { controller.abort(); } + let timedOut = false; + const timeout = window.setTimeout(() => { timedOut = true; controller.abort(); }, 30000); + try { + const response = await ServerConnection.makeRequest(apiUrl(path, target), { + ...init, signal: controller.signal, cache: 'no-store', + headers: { Accept: 'application/json', ...(init.headers || {}) } + }, settings); + const text = await response.text(); + let body: unknown = undefined; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + if (response.ok) { + throw new Error('The Jupyter server did not return status JSON. Check your sign-in and the TauGrid server extension.'); + } + body = text; + } + if (!response.ok) { + const detail = typeof body === 'object' && body !== null && typeof (body as Record).message === 'string' + ? String((body as Record).message) + : `HTTP ${response.status}`; + throw new Error(detail); + } + return body as T; + } catch (error) { + if (timedOut) { throw new Error('The Jupyter server did not respond within 30 seconds. Check its connection and retry.'); } + throw error; + } finally { + window.clearTimeout(timeout); + init.signal?.removeEventListener('abort', abort); + } +} + +export function apiGet(path: string, target?: ApiQuery, signal?: AbortSignal): Promise { + return request(path, { method: 'GET', signal }, target); +} + +export function apiPost(path: string, body: unknown, signal?: AbortSignal): Promise { + return request(path, { + method: 'POST', + body: JSON.stringify(body ?? {}), + headers: { 'Content-Type': 'application/json' }, + signal + }); +} diff --git a/sdk/python/python/labextension/src/explorer.tsx b/sdk/python/python/labextension/src/explorer.tsx new file mode 100644 index 00000000..399cb789 --- /dev/null +++ b/sdk/python/python/labextension/src/explorer.tsx @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { apiGet } from './api'; +import { LogSnapshot, parseLogs, parseRuns, portalLink, RunList, RunStatus, RunTarget } from './model'; +import { SnapshotRequest, SnapshotState } from './snapshot'; + +function useSnapshot(): [SnapshotState, React.MutableRefObject | null>] { + const [state, setState] = React.useState>({ busy: false, value: null, error: null }); + const request = React.useRef | null>(null); + React.useEffect(() => { + const current = new SnapshotRequest(setState); + request.current = current; + return () => { current.dispose(); request.current = null; }; + }, []); + return [state, request]; +} + +export function RunRows({ result, onSelect }: { result: RunList; onSelect: (target: RunTarget) => void }): JSX.Element { + const partial = result.truncated || result.warnings.length > 0; + return
+ {partial &&
+

This list is partial. Missing rows do not prove that runs are absent.

+ {result.warnings.map((message, index) =>

{message}

)} + {result.truncated &&

Discovery reached the 500-object per-kind limit. Use an exact name for runs outside this page.

} +
} + {result.runs.length ? + + + + + + + + + {result.runs.map(row => + + + + + + )} +
Tau-managed runs in this namespace.
RunKindStateQueueCreated
{row.kind}{row.state || 'Unknown'}{row.queue || 'None'}{row.created || 'Not reported'}
:

{partial + ? 'No matching runs in the available portion of this list.' + : 'No Tau-managed runs match this namespace and queue.'}

} +
; +} + +export function RunBrowser({ namespace, onSelect, portalUrl }: { namespace: string; onSelect: (target: RunTarget) => void; portalUrl: string | null }): JSX.Element { + const [queue, setQueue] = React.useState(''); + const [state, request] = useSnapshot(); + const id = React.useId(); + const link = portalLink(portalUrl); + const namespaceValue = namespace.trim(); + const reload = React.useCallback((): void => { + if (!namespaceValue) { request.current?.reset(); return; } + void request.current?.load(async signal => + parseRuns(await apiGet('runs', { namespace: namespaceValue, queue: queue.trim() }, signal))); + }, [namespaceValue, queue]); + // The list follows the namespace and queue without a submit: the sidebar's job + // is to show what is there. Debounced so typing a namespace does not fire a + // request per keystroke. + React.useEffect(() => { + const timer = window.setTimeout(reload, 250); + return () => window.clearTimeout(timer); + }, [reload]); + return
+

Live runs

+

Tau-managed Jobs and RayJobs in {namespaceValue || '(enter a namespace)'}, newest first. This is live cluster state, not durable history.

+
{ event.preventDefault(); reload(); }}> + + +
+
{state.busy ? 'Reading live runs...' : state.value ? `${state.value.runs.length} matching runs returned.` : ''}
+ {state.error &&

{state.error}

} + {state.value && } + {link && Open configured portal for history (new tab)} +
; +} + +export function LogContent({ value }: { value: LogSnapshot }): JSX.Element { + return
+

Snapshot for {value.pod}/{value.container}. {value.possiblyTruncated ? 'Byte limit reached; output may be truncated.' : 'Only the requested tail is shown; older lines may exist.'}

+
{value.text || '(No log lines returned)'}
+
; +} + +export function LogViewer({ status }: { status: RunStatus }): JSX.Element { + const pods = status.pods.filter(pod => pod.containers?.length); + const [pod, setPod] = React.useState(pods[0]?.name || ''); + const selected = pods.find(item => item.name === pod); + const [container, setContainer] = React.useState(selected?.containers?.[0] || ''); + const [tail, setTail] = React.useState('200'); + const [previous, setPrevious] = React.useState(false); + const [timestamps, setTimestamps] = React.useState(false); + const [state, request] = useSnapshot(); + const id = React.useId(); + const load = (event: React.FormEvent): void => { + event.preventDefault(); + void request.current?.load(async signal => parseLogs(await apiGet('logs', { + namespace: status.namespace, name: status.name, kind: status.kind || 'RayJob', + pod, container, tail, previous: String(previous), timestamps: String(timestamps) + }, signal))); + }; + return
+

Container logs

+

Manual snapshot only, at most 64 KiB. No streaming, automatic log checks or durable log archive.

+ {!pods.length ?

No verified pod/container choices. Refresh run status to retry discovery.

:
+ + + + + + +
} +
{state.busy ? 'Reading bounded log snapshot...' : ''}
+ {state.error &&

{state.error}

} + {state.value && } +
; +} diff --git a/sdk/python/python/labextension/src/index.ts b/sdk/python/python/labextension/src/index.ts new file mode 100644 index 00000000..1c43a50e --- /dev/null +++ b/sdk/python/python/labextension/src/index.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { ILayoutRestorer, JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { Dialog, ICommandPalette, ReactWidget, showDialog, ToolbarButton, WidgetTracker } from '@jupyterlab/apputils'; +import { ILauncher } from '@jupyterlab/launcher'; +import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook'; +import { RunTarget } from './model'; +import { SubmissionConfirmation, SubmissionReview } from './submitview'; +import { About, NotebookHandle, RunDetail, RunLogs, RunsSidebar, runWidgetId, SurfaceWidget } from './widget'; + +const plugin: JupyterFrontEndPlugin = { + id: 'taugrid:plugin', + description: 'TauGrid runs and notebook submission', + autoStart: true, + optional: [ILayoutRestorer, ILauncher, ICommandPalette, INotebookTracker], + activate: (app: JupyterFrontEnd, restorer: ILayoutRestorer | null, launcher: ILauncher | null, palette: ICommandPalette | null, notebooks: INotebookTracker | null): void => { + const tracker = new WidgetTracker({ namespace: 'taugrid-runs' }); + const runTabs = new Map(); + const identities = new WeakMap(); + const reviews = new WeakMap(); + const about = (): void => { void showDialog({ title: 'About TauGrid', body: ReactWidget.create(React.createElement(About)), buttons: [Dialog.okButton({ label: 'Close' })] }); }; + const openRun = async (target: RunTarget, surface = 'detail'): Promise => { + const id = runWidgetId(target, surface); + let widget = runTabs.get(id); + if (!widget || widget.isDisposed) { + widget = new SurfaceWidget(surface === 'logs' ? React.createElement(RunLogs, { target }) : React.createElement(RunDetail, { target, onOpenLogs: identity => { void openRun(identity, 'logs'); } })); + widget.id = id; + widget.title.label = (surface === 'logs' ? 'Logs: ' : (target.kind || 'RayJob') + ': ') + target.name; + widget.title.caption = target.namespace + '/' + target.kind + '/' + target.name; + widget.title.closable = true; + runTabs.set(id, widget); + identities.set(widget, { ...target, surface }); + widget.disposed.connect(() => { runTabs.delete(id); }); + } + if (!widget.isAttached) app.shell.add(widget, 'main'); + if (!tracker.has(widget)) await tracker.add(widget); + if (!widget.isDisposed) app.shell.activateById(widget.id); + }; + const sidebar = new SurfaceWidget(React.createElement(RunsSidebar, { onOpenRun: target => { void openRun(target); }, onAbout: about })); + sidebar.id = 'taugrid-runs'; + sidebar.title.label = 'TauGrid'; + sidebar.title.caption = 'TauGrid runs'; + sidebar.title.closable = false; + app.shell.add(sidebar, 'left', { rank: 700 }); + restorer?.add(sidebar, 'taugrid-runs'); + app.commands.addCommand('taugrid:open', { + label: 'TauGrid: Open runs', + execute: () => { app.shell.activateById(sidebar.id); } + }); + app.commands.addCommand('taugrid:open-run', { + label: 'TauGrid: Open run details', + execute: args => { + if (typeof args.namespace !== 'string' || !args.namespace.trim() || typeof args.name !== 'string' || !args.name.trim()) return; + if (args.kind !== undefined && args.kind !== 'Job' && args.kind !== 'RayJob') return; + return openRun({ namespace: args.namespace.trim(), name: args.name.trim(), kind: args.kind || 'RayJob' }, args.surface === 'logs' ? 'logs' : 'detail'); + } + }); + if (restorer) void restorer.restore(tracker, { + command: 'taugrid:open-run', + args: widget => ({ ...identities.get(widget)! }), + name: widget => widget.id + }); + const openReview = (panel: NotebookPanel): void => { + if (panel.isDisposed) return; + let widget = reviews.get(panel); + if (!widget || widget.isDisposed) { + const path = panel.context.path; + const notebook: NotebookHandle = { + name: path, + toJSON: () => { + if (panel.isDisposed) throw new Error('The source notebook was closed. Reopen it and start a new submission review.'); + if (panel.context.path !== path) throw new Error('The source notebook was renamed. Close this review and submit again from the notebook toolbar.'); + return JSON.stringify(panel.context.model.toJSON()); + } + }; + widget = new SurfaceWidget(React.createElement(SubmissionReview, { + notebook, + onAbout: about, + onOpenRun: target => { void openRun(target); }, + onConfirm: async plan => { + const result = await showDialog({ title: 'Confirm submission', body: ReactWidget.create(React.createElement(SubmissionConfirmation, { plan })), buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Confirm submission' })] }); + return result.button.accept; + } + })); + widget.id = 'taugrid-review-' + panel.id; + widget.title.label = 'Submit: ' + path.split('/').pop(); + widget.title.caption = 'Review submission for ' + path; + widget.title.closable = true; + reviews.set(panel, widget); + } + if (!widget.isAttached) app.shell.add(widget, 'main'); + app.shell.activateById(widget.id); + }; + const activeNotebook = (): NotebookPanel | null => { + const panel = notebooks?.currentWidget; + return panel && !panel.isDisposed && app.shell.currentWidget === panel ? panel : null; + }; + app.commands.addCommand('taugrid:submit-notebook', { + label: 'TauGrid: Submit current notebook', + isEnabled: () => !!activeNotebook(), + execute: () => { const panel = activeNotebook(); if (panel) openReview(panel); } + }); + app.commands.addCommand('taugrid:about', { label: 'TauGrid: About', execute: about }); + const toolbarPanels = new WeakSet(); + const addToolbar = (panel: NotebookPanel): void => { + if (toolbarPanels.has(panel)) return; + toolbarPanels.add(panel); + panel.toolbar.addItem('taugrid-submit', new ToolbarButton({ label: 'Submit to TauGrid', tooltip: 'Review this notebook before submitting to TauGrid', onClick: () => openReview(panel) })); + }; + notebooks?.forEach(addToolbar); + notebooks?.widgetAdded.connect((sender, panel) => addToolbar(panel)); + app.shell.currentChanged?.connect(() => app.commands.notifyCommandChanged('taugrid:submit-notebook')); + notebooks?.currentChanged.connect(() => app.commands.notifyCommandChanged('taugrid:submit-notebook')); + for (const command of ['taugrid:open', 'taugrid:submit-notebook', 'taugrid:about']) palette?.addItem({ command, category: 'TauGrid' }); + launcher?.add({ command: 'taugrid:open', category: 'TauGrid', rank: 12 }); + } +}; + +export default plugin; diff --git a/sdk/python/python/labextension/src/loss.tsx b/sdk/python/python/labextension/src/loss.tsx new file mode 100644 index 00000000..4eedb95c --- /dev/null +++ b/sdk/python/python/labextension/src/loss.tsx @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { LossMetrics } from './model'; + +export function LossSection({ metrics, stale = false }: { metrics?: LossMetrics; stale?: boolean }): JSX.Element { + const id = React.useId(); + const samples = metrics?.samples || []; + const minimum = samples.length ? Math.min(...samples.map(sample => sample.value)) : 0; + const maximum = samples.length ? Math.max(...samples.map(sample => sample.value)) : 0; + const scale = Math.max(Math.abs(minimum), Math.abs(maximum), 1); + const span = maximum / scale - minimum / scale; + const first = samples[0]?.step || 0; + const last = samples[samples.length - 1]?.step || first; + const points = samples.map(sample => ({ + x: last === first ? 334 : 60 + 548 * ((sample.step - first) / (last - first)), + y: span === 0 ? 126 : 222 - 192 * ((sample.value / scale - minimum / scale) / span), + ...sample + })); + return
+

Loss

+

{metrics?.source ? Object.entries(metrics.source).map(([key, value]) => `${key}: ${value}`).join(' / ') : 'No loss source available.'}

+

{stale || metrics?.stale ? 'Stale evidence. ' : ''}{metrics?.checkedAt ? `Source checked ${metrics.checkedAt}. ` : 'Source not checked. '}{samples.length} samples{samples.length ? `; steps ${first} to ${last}` : ''}.

+ {metrics?.possiblyTruncated &&

Partial window, not complete training history. {metrics.truncationReasons.join(', ')}. Limit: {metrics.limitBytes} bytes / {metrics.maxPoints} points.

} + {metrics?.coverage &&

Source coverage: {JSON.stringify(metrics.coverage)}

} + {metrics?.message &&

{metrics.message}

} + {samples.length ? <> + + Observed loss by step + {samples.length} observed samples, steps {first} through {last}; loss {minimum} to {maximum}. Straight lines connect observations, not interpolated samples. Exact values are in the sample table. + + {points.length > 1 && `${point.x},${point.y}`).join(' ')} fill="none" stroke="currentColor" strokeWidth="2" />} + {points.map(point => )} + Step + Loss + {first}{last} + +

Loss range: {minimum} to {maximum}.

+
Show observed loss samples ({samples.length}) +
+ {samples.map(sample => )} +
Observed loss samples
StepLoss
{sample.step}{sample.value}
+
+ :

No finite loss samples available. No curve is inferred.

} +
; +} diff --git a/sdk/python/python/labextension/src/model.ts b/sdk/python/python/labextension/src/model.ts new file mode 100644 index 00000000..b675a7c1 --- /dev/null +++ b/sdk/python/python/labextension/src/model.ts @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export interface RunTarget { + namespace: string; + name: string; + kind?: 'Job' | 'RayJob'; +} + +export interface Phase { + key: string; + label: string; + state: 'done' | 'active' | 'pending' | 'warning' | 'unknown' | 'skipped'; + detail: string; + hint: string; +} + +export interface RunList { + runs: (RunTarget & { state: string; queue?: string | null; created?: string | null })[]; + warnings: string[]; + truncated: boolean; +} + +export interface LogSnapshot { + pod: string; + container: string; + text: string; + limitBytes: number; + possiblyTruncated: boolean; +} + +const isRecord = (entry: unknown): entry is Record => + typeof entry === 'object' && entry !== null && !Array.isArray(entry); +const strings = (entry: unknown): entry is string[] => Array.isArray(entry) && entry.every(value => typeof value === 'string'); +const count = (entry: unknown): boolean => typeof entry === 'number' && Number.isSafeInteger(entry) && entry >= 0; +const optionalText = (entry: Record, keys: string[]): boolean => + keys.every(key => entry[key] == null || typeof entry[key] === 'string'); +const kindValid = (entry: unknown): boolean => entry === 'Job' || entry === 'RayJob'; + +export function portalLink(value: unknown): string | null { + if (typeof value !== 'string' || /[\s\\]/.test(value)) { return null; } + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : null; + } catch { return null; } +} + +export function parseRuns(value: unknown): RunList { + if (!isRecord(value) || !Array.isArray(value.runs) || !strings(value.warnings) || typeof value.truncated !== 'boolean' || + !value.runs.every(row => isRecord(row) && typeof row.name === 'string' && typeof row.namespace === 'string' && + kindValid(row.kind) && typeof row.state === 'string' && ['queue', 'created'].every(key => row[key] == null || typeof row[key] === 'string'))) { + throw new Error('The Jupyter server returned an invalid run list.'); + } + return value as unknown as RunList; +} + +export function parseLogs(value: unknown): LogSnapshot { + if (!isRecord(value) || !['pod', 'container', 'text'].every(key => typeof value[key] === 'string') || + value.limitBytes !== 65536 || typeof value.possiblyTruncated !== 'boolean' || new TextEncoder().encode(value.text as string).length > 65536) { + throw new Error('The Jupyter server returned an invalid log snapshot.'); + } + return value as unknown as LogSnapshot; +} + +export interface RunStatus extends RunTarget { + uid?: string; + metrics?: LossMetrics; + phases?: Phase[]; + output?: { path?: string | null; pvc?: string | null; portalUrl?: string | null }; + existing: boolean; + state?: string | null; + displayState?: string | null; + rayClusterName?: string | null; + jobId?: string | null; + deploymentStatus?: string | null; + queue?: string | null; + admitted?: boolean | null; + message?: string | null; + readyPods: number; + totalPods: number; + terminal: boolean; + pods: { + name: string; + phase?: string | null; + node?: string | null; + ready: boolean; + restarts: number; + rayNodeType?: string | null; + role?: string | null; + uid?: string | null; + containers?: string[]; + }[]; + diagnostics: { + code: string; + severity: string; + message: string; + suggestion?: string | null; + }[]; +} + +export function parseRunStatus(value: unknown): RunStatus { + const output = isRecord(value) ? value.output : undefined; + if (isRecord(value) && ( + !(value.kind == null || kindValid(value.kind)) || + !(value.phases == null || (Array.isArray(value.phases) && value.phases.every(phase => isRecord(phase) && + ['key', 'label', 'detail', 'hint'].every(key => typeof phase[key] === 'string') && + ['done', 'active', 'pending', 'warning', 'unknown', 'skipped'].includes(String(phase.state))))) || + !(output == null || (isRecord(output) && ['path', 'pvc', 'portalUrl'].every(key => output[key] == null || typeof output[key] === 'string'))) || + (Array.isArray(value.pods) && value.pods.some(pod => isRecord(pod) && pod.containers != null && !strings(pod.containers))) + )) { throw new Error('The Jupyter server returned an invalid run status.'); } + const count = (entry: unknown): boolean => + typeof entry === 'number' && Number.isSafeInteger(entry) && entry >= 0; + if (!isRecord(value) || typeof value.name !== 'string' || typeof value.namespace !== 'string' || + typeof value.existing !== 'boolean' || typeof value.terminal !== 'boolean' || + !count(value.readyPods) || !count(value.totalPods) || + !(value.admitted == null || typeof value.admitted === 'boolean') || + !optionalText(value, ['state', 'displayState', 'rayClusterName', 'jobId', 'deploymentStatus', 'queue', 'message']) || + !Array.isArray(value.pods) || !value.pods.every(pod => isRecord(pod) && + typeof pod.name === 'string' && typeof pod.ready === 'boolean' && count(pod.restarts) && + optionalText(pod, ['phase', 'node', 'rayNodeType'])) || + !Array.isArray(value.diagnostics) || !value.diagnostics.every(note => isRecord(note) && + typeof note.code === 'string' && typeof note.severity === 'string' && typeof note.message === 'string' && + optionalText(note, ['suggestion']))) { + throw new Error('The Jupyter server returned invalid run status. Check the TauGrid server extension version and retry.'); + } + return { ...value, ...(value.metrics === undefined ? {} : { metrics: parseMetrics(value.metrics) }) } as unknown as RunStatus; +} + +export interface LossMetrics { + state: 'ready' | 'empty' | 'unavailable' | 'error'; + source: Record | null; + samples: { step: number; value: number }[]; + checkedAt: string | null; + stale: boolean; + limitBytes: number; + maxPoints: number; + possiblyTruncated: boolean; + truncationReasons: string[]; + message: string; + coverage?: Record | null; +} + +export function parseMetrics(value: unknown): LossMetrics { + if (isRecord(value) && ['ready', 'empty', 'unavailable', 'error'].includes(String(value.state)) && + (value.source === null || (isRecord(value.source) && Object.values(value.source).every(entry => typeof entry === 'string'))) && + Array.isArray(value.samples) && value.samples.length <= 512 && value.samples.every((sample, index, samples) => + isRecord(sample) && typeof sample.step === 'number' && Number.isSafeInteger(sample.step) && sample.step >= 0 && + typeof sample.value === 'number' && Number.isFinite(sample.value) && (index === 0 || sample.step > samples[index - 1].step)) && + (value.checkedAt === null || (typeof value.checkedAt === 'string' && Number.isFinite(Date.parse(value.checkedAt)))) && + typeof value.stale === 'boolean' && value.limitBytes === 65536 && value.maxPoints === 512 && + typeof value.possiblyTruncated === 'boolean' && strings(value.truncationReasons) && typeof value.message === 'string' && + (value.coverage == null || isRecord(value.coverage))) return value as unknown as LossMetrics; + return { state: 'error', source: null, samples: [], checkedAt: null, stale: true, limitBytes: 65536, maxPoints: 512, + possiblyTruncated: true, truncationReasons: [], message: 'Invalid metrics evidence from the server. Lifecycle status remains available.' }; +} + +export function describeRun(status: RunStatus): { + label: string; + detail: string; + tone: 'neutral' | 'info' | 'warning' | 'error' | 'success'; +} { + switch (status.state?.toLowerCase()) { + case 'queued': + return { + label: status.admitted === false ? 'Waiting for admission' : 'Queued', + detail: status.admitted === false + ? 'Kueue has not admitted this run. Check queue capacity and the admission diagnostics.' + : 'Execution is not running yet. Check admission and pod scheduling below.', + tone: 'warning' + }; + case 'running': + return { + label: 'Running', + detail: 'The workload reports an active run. Pod readiness is not a measure of training health.', + tone: 'info' + }; + case 'failed': + return { + label: 'Failed', + detail: 'The run stopped with an error. Start with the diagnostics and inspect the affected pod logs.', + tone: 'error' + }; + case 'complete': + return { + label: 'Finished', + detail: 'The workload reports completion. Retrieve outputs from the run\'s configured storage.', + tone: 'success' + }; + case 'not_submitted': + return { + label: 'No such run', + detail: 'No workload was found. Check the kind, namespace and exact resource name, then check the run again.', + tone: 'neutral' + }; + default: + return { + label: 'Unreachable', + detail: 'Run status could not be determined. Check the Jupyter server\'s cluster connection and read permissions, then retry.', + tone: 'error' + }; + } +} + +export function canWatch(status: RunStatus | null): boolean { + return Boolean(status?.existing && !status.terminal && + ['running', 'queued'].includes(status.state?.toLowerCase() || '')); +} + +export interface SubmitPlanSummary { + submissionMode?: string; + retentionSeconds?: number; + submitterImage?: string; + name: string; + namespace: string; + queue: string; + profile: string; + planDigest: string; + payloadDigest: string; + notebookBytes: number; + preparedBytes: number; + encodedEnvBytes: number; + excludedCells: string[]; + /** Files chosen in the review to ship beside the notebook. */ + includedFiles?: string[]; +} + +export interface SubmitPreview { + submittable: boolean; + submissionEnabled: boolean; + plan: SubmitPlanSummary; +} + +export interface SubmitResponse { + submitted: boolean; + name: string; + namespace: string; + kind: string; + payloadDigest: string; + plan: SubmitPlanSummary; +} + +export interface NamespaceRow { + name: string; + tauEnabled: boolean; +} + +export interface NamespaceList { + namespaces: NamespaceRow[]; + warnings: string[]; +} + +export function parseNamespaces(value: unknown): NamespaceList { + if (!isRecord(value) || !Array.isArray(value.namespaces)) { + throw new Error('invalid namespace list'); + } + const namespaces = value.namespaces.map(entry => { + if (!isRecord(entry) || typeof entry.name !== 'string' || typeof entry.tauEnabled !== 'boolean') { + throw new Error('invalid namespace row'); + } + return { name: entry.name, tauEnabled: entry.tauEnabled }; + }); + const warnings = Array.isArray(value.warnings) ? value.warnings.filter((item): item is string => typeof item === 'string') : []; + return { namespaces, warnings }; +} + +export interface Capabilities { + submissionEnabled: boolean; + submissionImplemented: boolean; + portalUrl: string | null; +} + +export function parseCapabilities(value: unknown): Capabilities { + if (!isRecord(value) || typeof value.submissionEnabled !== 'boolean' || typeof value.submissionImplemented !== 'boolean') { + throw new Error('The Jupyter server returned invalid capabilities.'); + } + return { submissionEnabled: value.submissionEnabled, submissionImplemented: value.submissionImplemented, portalUrl: portalLink(value.portalUrl) }; +} + +export function parseSubmitPreview(value: unknown): SubmitPreview { + const plan = isRecord(value) ? value.plan : null; + if (!isRecord(value) || value.submittable !== true || typeof value.submissionEnabled !== 'boolean' || !isRecord(plan) || + !['name', 'namespace', 'queue', 'profile', 'payloadDigest'].every(key => typeof plan[key] === 'string' && Boolean(plan[key])) || + typeof plan.planDigest !== 'string' || !/^[a-f0-9]{64}$/.test(plan.planDigest) || + !['notebookBytes', 'preparedBytes', 'encodedEnvBytes'].every(key => count(plan[key])) || !strings(plan.excludedCells) || + !(plan.includedFiles == null || strings(plan.includedFiles)) || + !optionalText(plan, ['submissionMode', 'submitterImage']) || !(plan.retentionSeconds == null || count(plan.retentionSeconds))) { + throw new Error('The Jupyter server returned an invalid submission preview.'); + } + return value as unknown as SubmitPreview; +} + +export function parseSubmitResponse(value: unknown, plan: SubmitPlanSummary): SubmitResponse { + if (!isRecord(value) || value.submitted !== true || value.name !== plan.name || value.namespace !== plan.namespace || + value.kind !== 'RayJob' || value.payloadDigest !== plan.payloadDigest || !isRecord(value.plan) || value.plan.planDigest !== plan.planDigest) { + throw new Error('The Jupyter server did not confirm the reviewed run. Inspect the planned run before trying again.'); + } + return value as unknown as SubmitResponse; +} diff --git a/sdk/python/python/labextension/src/monitor.ts b/sdk/python/python/labextension/src/monitor.ts new file mode 100644 index 00000000..49069526 --- /dev/null +++ b/sdk/python/python/labextension/src/monitor.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { canWatch, RunStatus, RunTarget } from './model'; + +export interface MonitorState { + target: RunTarget | null; + status: RunStatus | null; + error: string | null; + busy: boolean; + watching: boolean; + checkedAt: number | null; + watchMessage?: string; +} + +interface Clock { + set(callback: () => void): number; + clear(handle: number): void; + now(): number; +} + +export class RunMonitor { + state: MonitorState = { + target: null, status: null, error: null, busy: false, watching: false, checkedAt: null + }; + private timer: number | undefined; + private request: AbortController | null = null; + private disposed = false; + private autoStarted = false; + private watchStarted: number | null = null; + + constructor( + private read: (target: RunTarget, signal: AbortSignal) => Promise, + private changed: (state: MonitorState) => void, + private clock: Clock = { + set: callback => window.setTimeout(callback, 10000), + clear: handle => window.clearTimeout(handle), + now: () => Date.now() + }, + private autoWatch = false + ) {} + + lookup(target: RunTarget): void { + if (this.disposed) { return; } + this.request?.abort(); + this.autoStarted = false; + this.watchStarted = null; + this.request = null; + this.clearTimer(); + this.state = { + target, status: null, error: null, busy: false, watching: false, checkedAt: null + }; + this.refresh(); + } + + refresh(): void { + if (this.disposed || this.request || !this.state.target) { return; } + this.clearTimer(); + const request = new AbortController(); + this.request = request; + this.publish({ busy: true }); + void this.read(this.state.target, request.signal).then(status => { + if (this.disposed || this.request !== request) { return; } + const now = this.clock.now(); + let watching = this.state.watching && canWatch(status); + let watchMessage = this.state.watchMessage; + if (this.autoWatch && !this.autoStarted && canWatch(status)) { + this.autoStarted = true; + this.watchStarted = now; + watching = true; + } + if (watching && this.watchStarted !== null && now - this.watchStarted >= 3600000) { + watching = false; + watchMessage = 'Watching paused after one hour. Start watching to continue.'; + } + if (status.terminal) { + watchMessage = 'Run finished. Automatic watch stopped; review final evidence and refresh to retry unavailable metrics.'; + } + this.publish({ status, error: null, checkedAt: now, watching, watchMessage }); + }).catch(error => { + if (this.disposed || this.request !== request) { return; } + this.publish({ + error: error instanceof Error ? error.message : String(error), watching: false + }); + }).finally(() => { + if (this.disposed || this.request !== request) { return; } + this.request = null; + this.publish({ busy: false }); + this.schedule(); + }); + } + + setWatching(watching: boolean): void { + if (this.disposed) { return; } + this.autoStarted = true; + this.watchStarted = watching ? this.clock.now() : null; + this.publish({ watchMessage: watching ? undefined : 'Watching paused.' }); + this.clearTimer(); + this.publish({ watching: watching && !this.state.error && canWatch(this.state.status) }); + this.schedule(); + } + + dispose(): void { + this.disposed = true; + this.request?.abort(); + this.request = null; + this.clearTimer(); + } + + private publish(update: Partial): void { + this.state = { ...this.state, ...update }; + this.changed(this.state); + } + + private clearTimer(): void { + if (this.timer !== undefined) { this.clock.clear(this.timer); } + this.timer = undefined; + } + + private schedule(): void { + if (this.state.watching && !this.request && !this.disposed) { + this.timer = this.clock.set(() => { + this.timer = undefined; + if (this.watchStarted !== null && this.clock.now() - this.watchStarted >= 3600000) { + this.publish({ watching: false, watchMessage: 'Watching paused after one hour. Start watching to continue.' }); + return; + } + this.refresh(); + }); + } + } +} diff --git a/sdk/python/python/labextension/src/snapshot.ts b/sdk/python/python/labextension/src/snapshot.ts new file mode 100644 index 00000000..55a40c23 --- /dev/null +++ b/sdk/python/python/labextension/src/snapshot.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export interface SnapshotState { + busy: boolean; + value: T | null; + error: string | null; +} + +export class SnapshotRequest { + private controller: AbortController | null = null; + private disposed = false; + + constructor(private readonly changed: (state: SnapshotState) => void) {} + + reset(): void { + this.controller?.abort(); + this.controller = null; + if (!this.disposed) { this.changed({ busy: false, value: null, error: null }); } + } + + async load(read: (signal: AbortSignal) => Promise): Promise { + if (this.disposed) { return; } + this.reset(); + const controller = new AbortController(); + this.controller = controller; + this.changed({ busy: true, value: null, error: null }); + try { + const value = await read(controller.signal); + if (!controller.signal.aborted) { this.changed({ busy: false, value, error: null }); } + } catch (error) { + if (!controller.signal.aborted) { this.changed({ busy: false, value: null, error: error instanceof Error ? error.message : String(error) }); } + } finally { + if (this.controller === controller) { this.controller = null; } + } + } + + dispose(): void { + this.disposed = true; + this.reset(); + } +} diff --git a/sdk/python/python/labextension/src/submission.ts b/sdk/python/python/labextension/src/submission.ts new file mode 100644 index 00000000..724796e0 --- /dev/null +++ b/sdk/python/python/labextension/src/submission.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RunTarget, SubmitPlanSummary, SubmitPreview, parseSubmitPreview, parseSubmitResponse } from './model'; + +export interface SubmissionPayload { + notebook: string; + path: string; + namespace: string; + name?: string; + profile?: string; + queue?: string; + /** Files beside the notebook that should ship with it. */ + files?: string[]; +} + +export interface SubmissionState { + preview: SubmitPreview | null; + busy: boolean; + submitted: RunTarget | null; + attempted: RunTarget | null; + uncertain: boolean; + error: string | null; +} + +export class SubmissionSession { + state: SubmissionState = { preview: null, busy: false, submitted: null, attempted: null, uncertain: false, error: null }; + private request: AbortController | null = null; + private payload: SubmissionPayload | null = null; + private disposed = false; + private writing = false; + + constructor( + private post: (path: string, body: SubmissionPayload & { confirm?: boolean }, signal: AbortSignal) => Promise, + private changed: (state: SubmissionState) => void + ) {} + + async review(payload: SubmissionPayload): Promise { + if (this.disposed || this.writing || this.state.submitted) return; + this.invalidate(); + const request = new AbortController(); + this.request = request; + const captured = { ...payload }; + this.publish({ busy: true, error: null }); + try { + const preview = parseSubmitPreview(await this.post('preview', captured, request.signal)); + if (this.disposed || this.request !== request) return; + this.payload = captured; + this.publish({ preview }); + } catch (error) { + if (!this.disposed && this.request === request) this.publish({ error: String(error) }); + } finally { + if (!this.disposed && this.request === request) { + this.request = null; + this.publish({ busy: false }); + } + } + } + + async submit(plan: SubmitPlanSummary, confirmed: boolean, capabilityReady: boolean): Promise { + const preview = this.state.preview; + if (this.disposed || this.state.busy || this.state.submitted || !confirmed || !capabilityReady || + !preview?.submittable || !preview.submissionEnabled || preview.plan !== plan || !this.payload) return false; + const request = new AbortController(); + this.request = request; + this.writing = true; + const attempted: RunTarget = { namespace: plan.namespace, name: plan.name, kind: 'RayJob' }; + const payload = { ...this.payload, namespace: plan.namespace, name: plan.name, planDigest: plan.planDigest, confirm: true }; + this.payload = null; + this.publish({ preview: null, busy: true, error: null, attempted }); + try { + const result = parseSubmitResponse(await this.post('submit', payload, request.signal), plan); + if (this.disposed || this.request !== request) return false; + if (!result.submitted) throw new Error('The server did not confirm submission. Inspect the planned run before reviewing again.'); + this.publish({ submitted: { namespace: result.namespace, name: result.name, kind: result.kind === 'Job' ? 'Job' : 'RayJob' }, uncertain: false }); + return true; + } catch (error) { + if (!this.disposed && this.request === request) this.publish({ error: String(error), uncertain: true }); + return false; + } finally { + this.writing = false; + if (!this.disposed && this.request === request) { + this.request = null; + this.publish({ busy: false }); + } + } + } + + invalidate(): void { + if (this.disposed || this.writing) return; + this.request?.abort(); + this.request = null; + this.payload = null; + this.publish({ preview: null, busy: false, error: null }); + } + + dispose(): void { + this.disposed = true; + this.request?.abort(); + this.request = null; + this.payload = null; + } + + private publish(update: Partial): void { + this.state = { ...this.state, ...update }; + this.changed(this.state); + } +} diff --git a/sdk/python/python/labextension/src/submitview.tsx b/sdk/python/python/labextension/src/submitview.tsx new file mode 100644 index 00000000..bfb3f8b4 --- /dev/null +++ b/sdk/python/python/labextension/src/submitview.tsx @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { apiGet, apiPost } from './api'; +import { RunTarget, SubmitPlanSummary } from './model'; +import { SubmissionSession, SubmissionState } from './submission'; +import { NotebookHandle, useCapabilities } from './widget'; + +interface FileCandidate { + name: string; + size: number; +} + +interface FileList { + files: FileCandidate[]; + warnings: string[]; + budgetBytes?: number; +} + +function parseFileList(value: unknown): FileList { + const record = (entry: unknown): entry is Record => + typeof entry === 'object' && entry !== null && !Array.isArray(entry); + if (!record(value) || !Array.isArray(value.files)) { + throw new Error('invalid file list'); + } + const files = value.files.map(entry => { + if (!record(entry) || typeof entry.name !== 'string' || typeof entry.size !== 'number') { + throw new Error('invalid file candidate'); + } + return { name: entry.name, size: entry.size }; + }); + const warnings = Array.isArray(value.warnings) ? value.warnings.filter((item): item is string => typeof item === 'string') : []; + return { files, warnings, budgetBytes: typeof value.budgetBytes === 'number' ? value.budgetBytes : undefined }; +} + +/** Files beside the notebook that could ship inside the workload. */ +function useNotebookFiles(path: string): { value: FileList | null; error: string | null } { + const [value, setValue] = React.useState(null); + const [error, setError] = React.useState(null); + React.useEffect(() => { + const request = new AbortController(); + setError(null); + void apiGet('files', { path }, request.signal) + .then(result => { if (!request.signal.aborted) { setValue(parseFileList(result)); } }) + .catch(reason => { if (!request.signal.aborted) { setError(String(reason)); } }); + return () => request.abort(); + }, [path]); + return { value, error }; +} + +export function SubmissionConfirmation({ plan }: { plan: SubmitPlanSummary }): JSX.Element { + return

Create RayJob {plan.namespace}/{plan.name} using the reviewed notebook snapshot?

+

Queue: {plan.queue || 'Not specified'}. Profile: {plan.profile || 'Not specified'}.

+

This creates a workload using the Jupyter server's credentials. Edits since review are not included.

; +} + +export function SubmissionReview({ notebook, onConfirm, onOpenRun, onAbout }: { + notebook: NotebookHandle; + onConfirm: (plan: SubmitPlanSummary) => Promise; + onOpenRun: (target: RunTarget) => void; + onAbout: () => void; +}): JSX.Element { + const [namespace, setNamespace] = React.useState('ray'); + const [name, setName] = React.useState(''); + const [profile, setProfile] = React.useState(''); + const [queue, setQueue] = React.useState(''); + const [state, setState] = React.useState({ preview: null, busy: false, submitted: null, attempted: null, uncertain: false, error: null }); + const [localError, setLocalError] = React.useState(null); + const [confirming, setConfirming] = React.useState(false); + const capabilities = useCapabilities(); + const candidates = useNotebookFiles(notebook.name); + const [selected, setSelected] = React.useState([]); + const session = React.useRef(null); + const confirmingRef = React.useRef(false); + const id = React.useId(); + React.useEffect(() => { + const current = new SubmissionSession(apiPost, setState); + session.current = current; + return () => { current.dispose(); session.current = null; }; + }, [notebook]); + const locked = state.busy || confirming || !!state.submitted; + const review = (): void => { + setLocalError(null); + session.current?.invalidate(); + try { + void session.current?.review({ notebook: notebook.toJSON(), path: notebook.name, namespace: namespace.trim(), name: name.trim() || undefined, profile: profile.trim() || undefined, queue: queue.trim() || undefined, files: selected.length ? selected : undefined }); + } catch (error) { setLocalError(String(error)); } + }; + const submit = async (): Promise => { + const current = session.current; + const plan = state.preview?.plan; + if (!current || !plan || confirmingRef.current) return; + confirmingRef.current = true; + setConfirming(true); + try { + const accepted = await onConfirm(plan); + if (session.current !== current) return; + await current.submit(plan, accepted, !!capabilities.value?.submissionEnabled && !!capabilities.value?.submissionImplemented); + } catch (error) { + if (session.current === current) setLocalError(String(error)); + } finally { + confirmingRef.current = false; + if (session.current === current) setConfirming(false); + } + }; + const plan = state.preview?.plan; + return
+

Submit notebook

{notebook.name}

+

This review belongs to this notebook. Review captures its current contents; later edits require a new review.

+
{ event.preventDefault(); review(); }}> + + + + + +
+ {candidates.value && (candidates.value.files.length > 0 || candidates.value.warnings.length > 0) &&
+

Files to ship

+

Files beside this notebook. They are embedded in the workload, so the whole payload is capped{candidates.value.budgetBytes ? ` at ${Math.round(candidates.value.budgetBytes / 1024)} KiB` : ''}; anything larger belongs in the runtime image.

+
    + {candidates.value.files.map(file =>
  • + +
  • )} +
+ {candidates.value.warnings.map((warning, index) =>

{warning}

)} +
} + {candidates.error &&

Could not list files beside this notebook, so only the notebook itself will ship.

} + {plan && plan.includedFiles?.length ?

Shipping with the notebook: {plan.includedFiles.join(', ')}

: null} +

{state.submitted ? 'Submitted' : confirming ? 'Awaiting confirmation or submission result...' : state.busy ? 'Reviewing submission...' : plan ? 'Plan ready. No workload has been created.' : 'Review a plan before submitting.'}

+ {capabilities.error ?

{capabilities.error}

: !capabilities.value ?

Checking submission availability...

: !capabilities.value.submissionEnabled || !capabilities.value.submissionImplemented ?

Submission is disabled on this server. You can still review a plan.

: null} + {(state.error || localError) &&

{localError || state.error}

} + {state.uncertain && state.attempted &&

The write outcome is uncertain. Inspect the planned run before reviewing or submitting again. Closing this tab does not cancel a server-side submission.

} + {plan && <>

Submission plan

+ {Object.entries({ Namespace: plan.namespace, 'Run name': plan.name, Queue: plan.queue || 'Not specified', Profile: plan.profile || 'Not specified', 'Payload digest': plan.payloadDigest, 'Notebook bytes': plan.notebookBytes, 'Prepared bytes': plan.preparedBytes, 'Encoded environment bytes': plan.encodedEnvBytes }).map(([label, value]) =>
{label}
{value}
)} +

Submission mode: {plan.submissionMode || 'Not reported'}. Retention: {plan.retentionSeconds ?? 'Not reported'} seconds. Submitter image: {plan.submitterImage || 'Not reported'}.

Excluded cells: {plan.excludedCells.length ? plan.excludedCells.join(', ') : 'None'}.

+ {!state.preview?.submittable &&

This plan cannot be submitted on this server.

}} + {!state.submitted && } + {state.submitted &&

Submitted {state.submitted.namespace}/{state.submitted.name}

} +
; +} \ No newline at end of file diff --git a/sdk/python/python/labextension/src/view.tsx b/sdk/python/python/labextension/src/view.tsx new file mode 100644 index 00000000..ef81af68 --- /dev/null +++ b/sdk/python/python/labextension/src/view.tsx @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { describeRun, portalLink, RunStatus } from './model'; +import { LossSection } from './loss'; + +export function RunDetails({ status, stale, statusUrl }: { + status: RunStatus; + stale: boolean; + statusUrl: string; +}): JSX.Element { + const state = describeRun(status); + const unavailable = stale || state.label === 'Unreachable'; + const restarts = status.pods.reduce((total, pod) => total + pod.restarts, 0); + return ( +
+
+
+
+

{status.name}

+

Namespace {status.namespace}

+

{stale + ? 'The last check failed. Values below are from the last successful response, not the current cluster state.' + : state.detail}

+
+ + {status.existing &&
+
+
Admission
+
{status.admitted === true ? 'Admitted' : status.admitted === false ? 'Not admitted' : 'Not reported'}
+
Queue {status.queue || 'not reported'}
+
+
+
Pods
+
{status.readyPods} / {status.totalPods} ready
+
{restarts} restart{restarts === 1 ? '' : 's'} observed
+
+
+
Execution
+
{state.label}
+
Deployment {status.deploymentStatus || 'not reported'}
+
+
} + + {status.message &&

{status.message}

} + + {status.phases &&
+

Lifecycle evidence{stale ? ' (last known)' : ''}

+
    {status.phases.map(phase =>
  1. +
    {phase.label} {phase.state}
    +

    {phase.detail}

    {phase.hint &&

    Next action {phase.hint}

    } +
  2. )}
+
} + + + {status.output &&
+

Results

+

Recorded output path: {status.output.path || 'Not recorded'}

+

Recorded output PVC: {status.output.pvc || 'Not recorded'}

+

Metadata does not prove that files exist. This console does not download or proxy artifacts.

+ {portalLink(status.output.portalUrl) ? Open configured portal (new tab) : +

No portal configured. Use tau run get {status.name} with the appropriate namespace and CLI credentials.

} +
} + +
+
+

Diagnostics {status.diagnostics.length}

+ {status.diagnostics.length ?
    + {status.diagnostics.map((note, index) =>
  • + {note.severity} +

    {note.message}

    + {note.suggestion &&

    Next action {note.suggestion}

    } +
  • )} +
:

{unavailable + ? 'No diagnostics are available. Retry the status check.' + : 'No diagnostics returned. This does not confirm workload health.'}

} +
+ {status.existing &&
+

Pods {status.pods.length}

+ {status.pods.length ?
    + {status.pods.map(pod =>
  • +
    {pod.name} + {pod.ready ? 'Ready' : 'Not ready'} +
    +
    +
    Phase
    {pod.phase || 'Unknown'}
    +
    Role
    {pod.role || pod.rayNodeType || 'Not reported'}
    +
    Node
    {pod.node || 'Not assigned'}
    +
    Restarts
    {pod.restarts} restart{pod.restarts === 1 ? '' : 's'}
    +
    +
  • )} +
:

No pods returned. Check admission and diagnostics before assuming pods have not been created.

} +
} +
+ +
+ Run identifiers and source +
+
Ray cluster
{status.rayClusterName || 'Not reported'}
+
Ray job ID
{status.jobId || 'Not reported'}
+
Deployment
{status.deploymentStatus || 'Not reported'}
+
+ Open status JSON (new tab) +

Read through this Jupyter server. No Ray dashboard address is provided by the API.

+
+
+ ); +} diff --git a/sdk/python/python/labextension/src/widget.tsx b/sdk/python/python/labextension/src/widget.tsx new file mode 100644 index 00000000..6c3f3f70 --- /dev/null +++ b/sdk/python/python/labextension/src/widget.tsx @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { ReactWidget } from '@jupyterlab/apputils'; +import { apiGet, apiUrl } from './api'; +import { LogViewer, RunBrowser } from './explorer'; +import { canWatch, Capabilities, NamespaceList, parseCapabilities, parseNamespaces, parseRunStatus, RunTarget } from './model'; +import { MonitorState, RunMonitor } from './monitor'; +import { RunDetails } from './view'; + +export interface NotebookHandle { + name: string; + toJSON: () => string; +} + + +export function useCapabilities(): { value: Capabilities | null; error: string | null; retry: () => void } { + const [value, setValue] = React.useState(null); + const [error, setError] = React.useState(null); + const [attempt, setAttempt] = React.useState(0); + React.useEffect(() => { + const request = new AbortController(); + setValue(null); + setError(null); + void apiGet('capabilities', undefined, request.signal).then(result => { + if (!request.signal.aborted) setValue(parseCapabilities(result)); + }).catch(reason => { + if (!request.signal.aborted) setError(String(reason)); + }); + return () => request.abort(); + }, [attempt]); + return { value, error, retry: () => setAttempt(current => current + 1) }; +} + +export function useNamespaces(): { value: NamespaceList | null; error: string | null } { + const [value, setValue] = React.useState(null); + const [error, setError] = React.useState(null); + React.useEffect(() => { + const request = new AbortController(); + setError(null); + void apiGet('namespaces', undefined, request.signal) + .then(result => { if (!request.signal.aborted) { setValue(parseNamespaces(result)); } }) + .catch(reason => { if (!request.signal.aborted) { setError(String(reason)); } }); + return () => request.abort(); + }, []); + return { value, error }; +} + +export function RunsSidebar({ onOpenRun, onAbout }: { + onOpenRun: (target: RunTarget) => void; onAbout: () => void; +}): JSX.Element { + const [namespace, setNamespace] = React.useState(''); + const [name, setName] = React.useState(''); + const [kind, setKind] = React.useState<'Job' | 'RayJob'>('RayJob'); + const capabilities = useCapabilities(); + const namespaces = useNamespaces(); + const options = namespaces.value?.namespaces || []; + const id = React.useId(); + // Prefer a namespace that is actually set up for Tau work, and never require + // the researcher to guess or type one that cannot host the run. + React.useEffect(() => { + if (namespace || !options.length) { return; } + const preferred = options.find(option => option.tauEnabled) || options[0]; + setNamespace(preferred.name); + }, [namespace, options]); + const selected = options.find(option => option.name === namespace); + return
+

Runs

+ + + {options.map(option => )} + +

+ {namespaces.error ? 'Namespace list unavailable; type a namespace to continue.' + : !options.length ? 'No namespaces are visible to this server; type one to continue.' + : selected ? (selected.tauEnabled + ? 'This namespace is labelled for Tau work; queue admission is not guaranteed.' + : 'This namespace is not labelled for Tau work, so runs may stay unadmitted.') + : 'This namespace is not in the visible list.' + } + {namespaces.value?.warnings?.length ? ` ${namespaces.value.warnings.join(' ')}` : ''} +

+ +
Find exact run +
{ event.preventDefault(); if (namespace.trim() && name.trim()) onOpenRun({ namespace: namespace.trim(), name: name.trim(), kind }); }}> + + + +
+
+
; +} + +function useRun(target: RunTarget, metrics = false): [MonitorState, React.MutableRefObject] { + const [state, setState] = React.useState({ target, status: null, error: null, busy: false, watching: false, checkedAt: null }); + const monitor = React.useRef(null); + React.useEffect(() => { + const current = new RunMonitor(async (identity, signal) => parseRunStatus(await apiGet('status', { ...identity, ...(metrics ? { includeMetrics: 'true' } : {}) }, signal)), setState, undefined, metrics); + monitor.current = current; + current.lookup(target); + return () => { current.dispose(); monitor.current = null; }; + }, [target.namespace, target.kind, target.name, metrics]); + return [state, monitor]; +} + +function RunHeading({ target, logs = false }: { target: RunTarget; logs?: boolean }): JSX.Element { + return

{logs ? 'Logs: ' : ''}{target.name}

{target.namespace} / {target.kind} / Read only

; +} + +function ReadState({ state }: { state: MonitorState }): JSX.Element { + return <>

{state.busy ? 'Checking run...' : state.checkedAt ? 'Checked ' + new Date(state.checkedAt).toLocaleTimeString() : 'Waiting for first check.'}

+ {state.error &&

{state.error} {state.status ? 'Showing the last successful snapshot. Watch is stopped.' : 'Refresh to retry.'}

}; +} + +export function RunDetail({ target, onOpenLogs }: { target: RunTarget; onOpenLogs: (target: RunTarget) => void }): JSX.Element { + const [state, monitor] = useRun(target, true); + return
+ +
+ +
+ +

{state.error ? 'Watching stopped after a status error.' : state.watchMessage || (state.watching ? 'Watching status and bounded loss samples.' : 'Not watching.')}

+ {state.status && } +
; +} + +export function RunLogs({ target }: { target: RunTarget }): JSX.Element { + const [state, monitor] = useRun(target); + return
+ + + + {state.status && !state.error && !state.busy && } +
; +} + +export function About(): JSX.Element { + const capabilities = useCapabilities(); + return
+

Runs and logs use the Jupyter server's Kubernetes credentials and RBAC. On a shared server, those credentials may be shared. The browser never connects directly to Kubernetes.

+

{capabilities.error || (capabilities.value ? capabilities.value.submissionEnabled && capabilities.value.submissionImplemented ? 'Submission is enabled.' : 'Submission is disabled.' : 'Checking server capabilities...')}

+ {capabilities.error && } +

Submission requires the server operator to certify the notebook runtime and enable TAUGRID_SUBMISSION_ENABLED. Reviewing a plan does not create a run.

+

Durable history is available through an operator-configured portal link or the CLI's Kusto options. {capabilities.value?.portalUrl ? 'A portal link is configured.' : 'No portal link is configured.'} The browser never queries Kubernetes or portal APIs directly. The server may read bounded portal metrics only with separate operator opt-in and an exact run mapping.

+
; +} + +export function runWidgetId(target: RunTarget, surface = 'detail'): string { + return 'taugrid-' + surface + '-' + [target.namespace, target.kind || 'RayJob', target.name].map(value => encodeURIComponent(value)).join(':'); +} + +export class SurfaceWidget extends ReactWidget { + constructor(private element: React.ReactElement) { + super(); + this.addClass('taugrid-widget'); + this.node.tabIndex = -1; + } + protected onActivateRequest(): void { + const control = this.node.querySelector('button:not(:disabled), input:not(:disabled)'); + (control || this.node).focus(); + } + render(): React.ReactElement { return this.element; } +} diff --git a/sdk/python/python/labextension/style/index.css b/sdk/python/python/labextension/style/index.css new file mode 100644 index 00000000..4b76e26e --- /dev/null +++ b/sdk/python/python/labextension/style/index.css @@ -0,0 +1,894 @@ +/* Copyright (c) Microsoft Corporation. + Licensed under the MIT License. */ + +/* TauGrid plugin styling. + This is the TauGrid Portal design applied to the JupyterLab surfaces: the same + tokens, type scale, cards, stat tiles, badges, tables, buttons, focus ring and + chart treatment as portal/frontend/src/styles.css and stellar/charts.css. + + The Portal is a light-only product (color-scheme: light), so the plugin adopts + its fixed palette rather than following the JupyterLab theme. Chrome around the + panel still belongs to JupyterLab. */ + +.taugrid-widget, +.taugrid-panel { + --bg: #f4f6f8; + --panel: #fff; + --line: #e4e8ee; + --text: #1a2230; + --muted: #5c6a7d; + --accent: #2563eb; + --accent-bg: #eaf1fe; + --hover: #f2f6fc; + --ok: #15803d; + --ok-bg: #e7f6ec; + --warn: #b91c1c; + --warn-bg: #fdecec; + --amber: #b45309; + --amber-bg: #fef4e6; + --code-bg: #eef1f6; + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +} + +.taugrid-widget { + overflow: auto; + background: var(--bg); + color: var(--text); +} + +.taugrid-panel { + box-sizing: border-box; + max-width: 100%; + padding: 22px 24px 32px; + background: var(--bg); + color: var(--text); + font-size: 14px; +} + +.taugrid-panel *, +.taugrid-panel *::before, +.taugrid-panel *::after { + box-sizing: border-box; +} + +.taugrid-panel code { + background: var(--code-bg); + border-radius: 4px; + padding: 1px 5px; + font-family: ui-monospace, SFMono-Regular, "Cascadia Mono", Consolas, monospace; +} + +.taugrid-panel :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +/* --- page head ---------------------------------------------------------- */ + +.taugrid-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + max-width: 110ch; + margin-bottom: 18px; +} + +.taugrid-heading h1 { + margin: 0; + font-size: 20px; + font-weight: 750; + letter-spacing: -0.01em; +} + +.taugrid-heading h2 { + margin: 0; + font-size: 16px; +} + +.taugrid-heading p { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; +} + +/* --- controls ----------------------------------------------------------- */ + +.taugrid-find, +.taugrid-log-form, +.taugrid-review-form { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; +} + +.taugrid-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 120px; + font-size: 12px; + font-weight: 600; +} + +.taugrid-input { + min-width: 0; + height: 32px; + border: 1px solid #dfe6f7; + border-radius: 6px; + background: var(--panel); + color: var(--text); + padding: 5px 9px; + font: inherit; + font-weight: 400; +} + +select.taugrid-input { + max-width: 100%; +} + +.taugrid-button { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--accent); + padding: 6px 10px; + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.taugrid-button:hover:not(:disabled) { + background: var(--hover); +} + +.taugrid-button:disabled { + color: var(--muted); + cursor: not-allowed; + opacity: 0.55; +} + +.taugrid-primary { + border-color: transparent; + border-radius: 8px; + background: var(--accent); + color: #fff; + padding: 9px 16px; + font-weight: 650; +} + +.taugrid-primary:hover:not(:disabled) { + background: #1d4fd7; +} + +.taugrid-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.taugrid-watch { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 12px; +} + +/* --- banners, notes, errors -------------------------------------------- */ + +.taugrid-muted, +.taugrid-summary, +.taugrid-server-message { + color: var(--muted); +} + +.taugrid-summary, +.taugrid-server-message { + margin: 8px 0 0; + font-size: 12px; +} + +.taugrid-validation, +.taugrid-error, +.taugrid-error-text, +.taugrid-next-action { + overflow-wrap: anywhere; +} + +.taugrid-validation, +.taugrid-error, +.taugrid-error-text { + border: 1px solid #f4b9b9; + border-radius: 8px; + background: var(--warn-bg); + color: var(--warn); + font-size: 12px; + padding: 10px 12px; + margin: 12px 0 0; +} + +.taugrid-error h2 { + margin: 0 0 4px; + font-size: 14px; +} + +/* --- run list (sidebar) ------------------------------------------------- */ + +.taugrid-sidebar { + padding: 16px 14px 24px; + background: var(--bg); +} + +.taugrid-browser { + margin-top: 16px; +} + +.taugrid-namespace-hint { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.4; +} + +.taugrid-browser h2 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 12px; + color: var(--muted); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.taugrid-browser p { + margin: 0 0 12px; + font-size: 12px; +} + +/* Runs table. The Portal renders run collections as table.jobs, so the sidebar + matches that treatment rather than a list of cards. */ +.taugrid-runs-table { + width: 100%; + border-collapse: collapse; + margin-top: 12px; + font-size: 13px; +} + +.taugrid-runs-table caption { + color: var(--muted); + font-size: 12px; + text-align: left; + padding-bottom: 6px; +} + +.taugrid-runs-table th, +.taugrid-runs-table td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--line); + vertical-align: top; +} + +.taugrid-runs-table th { + color: var(--muted); + font-size: 12px; + font-weight: 650; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.taugrid-runs-table tbody tr:hover { + background: var(--hover); +} + +.taugrid-runs-table td { + overflow-wrap: anywhere; +} + +.taugrid-run-link { + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; + font-weight: 650; + padding: 0; + text-align: left; +} + +.taugrid-run-link:hover { + text-decoration: underline; +} +.taugrid-details { + margin-top: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.taugrid-details summary { + cursor: pointer; + padding: 13px 12px; + font-weight: 650; + font-size: 13px; +} + +.taugrid-exact { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; + border-top: 1px solid var(--line); + padding: 12px; +} + +/* --- run detail --------------------------------------------------------- */ + +.taugrid-run { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: 0 1px 2px rgb(20 30 50 / 4%); + padding: 22px; + margin-top: 18px; + max-width: 110ch; +} + +.taugrid-run-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 10px; +} + +.taugrid-state-line { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.taugrid-state-mark { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--muted); + flex: 0 0 auto; +} + +.taugrid-tone-success .taugrid-state-mark { background: var(--ok); } +.taugrid-tone-warning .taugrid-state-mark { background: var(--amber); } +.taugrid-tone-error .taugrid-state-mark { background: var(--warn); } +.taugrid-tone-info .taugrid-state-mark { background: var(--accent); } + +.taugrid-run-name { + margin: 0; + font-size: 20px; + font-weight: 750; + overflow-wrap: anywhere; +} + +.taugrid-run-namespace { + margin: 6px 0 0; + color: var(--muted); + font-size: 12px; +} + +/* stat tiles — the Portal's Overview headline metrics */ +.taugrid-signals { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 14px; + margin: 18px 0 0; +} + +.taugrid-signal { + border: 1px solid var(--line); + border-top: 3px solid transparent; + border-radius: 10px; + background: #fff; + padding: 16px 18px; + min-width: 0; +} + +.taugrid-signal dt { + margin: 0; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + line-height: 1.3; +} + +.taugrid-signal dd { + margin: 10px 0 0; + font-size: 28px; + font-weight: 760; + font-variant-numeric: tabular-nums; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.taugrid-signal-detail { + margin-top: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 400; + overflow-wrap: anywhere; +} + +.taugrid-signal.taugrid-tone-good, +.taugrid-signal.taugrid-tone-ready, +.taugrid-signal.taugrid-tone-complete { border-top-color: var(--ok); } +.taugrid-signal.taugrid-tone-good dd, +.taugrid-signal.taugrid-tone-ready dd, +.taugrid-signal.taugrid-tone-complete dd { color: var(--ok); } +.taugrid-signal.taugrid-tone-warn, +.taugrid-signal.taugrid-tone-queued { border-top-color: var(--amber); } +.taugrid-signal.taugrid-tone-warn dd, +.taugrid-signal.taugrid-tone-queued dd { color: var(--amber); } +.taugrid-signal.taugrid-tone-error, +.taugrid-signal.taugrid-tone-failed { border-top-color: var(--warn); } +.taugrid-signal.taugrid-tone-error dd, +.taugrid-signal.taugrid-tone-failed dd { color: var(--warn); } +.taugrid-signal.taugrid-tone-running, +.taugrid-signal.taugrid-tone-info { border-top-color: var(--accent); } +.taugrid-signal.taugrid-tone-running dd, +.taugrid-signal.taugrid-tone-info dd { color: var(--accent); } + +.taugrid-lifecycle, +.taugrid-observations { + margin-top: 26px; +} + +.taugrid-phase-list { + display: flex; + flex-direction: column; + gap: 10px; + list-style: none; + margin: 12px 0 0; + padding: 0; +} + +.taugrid-phase-list > li { + border: 1px solid var(--line); + border-left: 3px solid var(--muted); + border-radius: 10px; + background: #fff; + padding: 14px 16px; +} + +.taugrid-phase-list > li.taugrid-phase-done, +.taugrid-phase-list > li.taugrid-phase-active { border-left-color: var(--ok); } + +.taugrid-phase-list > li.taugrid-phase-pending { border-left-color: var(--amber); } + +.taugrid-phase-list > li.taugrid-phase-warning { border-left-color: var(--warn); } + +.taugrid-phase-list > li > div { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 14px; + font-weight: 700; +} + +.taugrid-phase-list > li > p { + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; +} + +.taugrid-phase-state, +.taugrid-badge { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + color: var(--muted); + font-size: 11px; + font-weight: 650; + padding: 2px 8px; +} + +/* --- pods --------------------------------------------------------------- */ + +.taugrid-pod-section h3, +.taugrid-diagnostics h3 { + display: flex; + align-items: center; + gap: 8px; + margin: 26px 0 12px; + color: var(--muted); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.taugrid-count { + color: var(--muted); + font-weight: 600; +} + +.taugrid-pods { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--line); +} + +.taugrid-pod { + border-bottom: 1px solid var(--line); + padding: 10px; + font-size: 13px; +} + +.taugrid-pod:hover { + background: var(--hover); +} + +.taugrid-pod-title { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.taugrid-readiness { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + color: var(--muted); + font-size: 11px; + font-weight: 650; + padding: 2px 8px; +} + +.taugrid-ready { + border-color: transparent; + background: var(--ok-bg); + color: var(--ok); +} + +.taugrid-pod-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 6px 14px; + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.taugrid-pod-facts div { + min-width: 0; +} + +.taugrid-pod-facts dt { + margin: 0; + color: var(--muted); +} + +.taugrid-pod-facts dd { + margin: 2px 0 0; + color: var(--text); + overflow-wrap: anywhere; +} + +/* --- diagnostics -------------------------------------------------------- */ + +.taugrid-note-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.taugrid-note { + border: 1px solid var(--line); + border-left: 3px solid var(--muted); + border-radius: 10px; + background: #fff; + padding: 14px 16px; +} + +.taugrid-note.taugrid-tone-error { border-left-color: var(--warn); } +.taugrid-note.taugrid-tone-warning { border-left-color: var(--amber); } +.taugrid-note.taugrid-tone-info { border-left-color: var(--accent); } + +.taugrid-note-severity { + display: inline-block; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + color: var(--muted); + font-size: 11px; + font-weight: 650; + padding: 2px 8px; + margin-bottom: 8px; + text-transform: capitalize; +} + +.taugrid-next-action { + margin: 8px 0 0; + border: 0; + background: transparent; + color: var(--muted); + padding: 0; + font-size: 12px; +} + +.taugrid-next-action strong { + color: var(--text); + font-weight: 650; +} + +.taugrid-identifiers { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.taugrid-identifiers div { + margin-top: 4px; +} + +/* --- logs --------------------------------------------------------------- */ + +.taugrid-log-text { + margin: 12px 0 0; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, "Cascadia Mono", Consolas, monospace; + font-size: 12px; + line-height: 1.45; + max-height: 60vh; + overflow: auto; + padding: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* --- metrics and the loss curve ---------------------------------------- */ + +.taugrid-metrics { + margin-top: 26px; +} + +/* .taugrid-loss-curve is the itself; the polyline and points draw with + currentColor, so the series colour is set here. */ +.taugrid-loss-curve { + display: block; + width: 100%; + height: 210px; + color: var(--accent); + background: + linear-gradient(90deg, rgb(232 232 232 / 45%) 1px, transparent 1px), + linear-gradient(180deg, rgb(232 232 232 / 55%) 1px, transparent 1px); + background-size: 64px 100%, 100% 56px; +} + +.taugrid-loss-curve text { + fill: #83899e; + font-size: 10px; + font-weight: 400; + font-variant-numeric: tabular-nums; +} + +.taugrid-loss-curve polyline { + stroke: currentColor; + stroke-width: 2; +} + +.taugrid-loss-curve circle { + fill: currentColor; + stroke: var(--panel); + stroke-width: 2; +} + +.taugrid-loss-samples { + margin-top: 12px; +} + +.taugrid-loss-table table { + width: 100%; + border-collapse: collapse; + margin-top: 12px; + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.taugrid-loss-table caption { + color: var(--muted); + font-size: 12px; + text-align: left; +} + +.taugrid-loss-table th, +.taugrid-loss-table td { + text-align: right; + padding: 8px 10px; + border-bottom: 1px solid var(--line); +} + +.taugrid-loss-table th:first-child, +.taugrid-loss-table td:first-child { + text-align: left; +} + +.taugrid-loss-table th { + color: var(--muted); + font-size: 12px; + font-weight: 650; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.taugrid-loss-table tbody tr:hover { + background: var(--hover); +} + +.taugrid-metrics-source, +.taugrid-metrics-freshness, +.taugrid-metrics-truncation, +.taugrid-metrics-empty, +.taugrid-metrics-error, +.taugrid-watch-state { + margin: 12px 0 0; + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} + +.taugrid-metrics-error { + border: 1px solid #f4b9b9; + border-radius: 8px; + background: var(--warn-bg); + color: var(--warn); + padding: 10px 12px; +} + +/* --- submission review -------------------------------------------------- */ + +.taugrid-review { + max-width: 100%; +} + +.taugrid-plan { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 14px; + margin: 18px 0 0; +} + +.taugrid-plan > div { + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + padding: 16px 18px; + min-width: 0; +} + +.taugrid-plan dt { + margin: 0; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.taugrid-plan dd { + margin: 10px 0 0; + font-size: 14px; + font-weight: 650; + overflow-wrap: anywhere; +} + +.taugrid-submitted { + margin: 12px 0 0; + border: 1px solid transparent; + border-radius: 6px; + background: var(--ok-bg); + color: var(--ok); + font-size: 13px; + font-weight: 650; + padding: 8px 12px; +} + +@media (max-width: 800px) { + .taugrid-panel { + padding: 16px; + } + + .taugrid-heading { + flex-direction: column; + gap: 8px; + } + + .taugrid-run { + padding: 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + .taugrid-panel * { + transition: none !important; + animation: none !important; + } +} +/* --- files to ship with the notebook ----------------------------------- */ + +.taugrid-files { + margin-top: 22px; +} + +.taugrid-files h2 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 12px; + color: var(--muted); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.taugrid-files p { + margin: 0 0 10px; +} + +.taugrid-file-list { + list-style: none; + margin: 12px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.taugrid-file { + display: flex; + align-items: center; + gap: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + padding: 10px 14px; + font-size: 13px; + cursor: pointer; +} + +.taugrid-file:hover { + border-color: var(--accent); +} + +.taugrid-file input { + flex: 0 0 auto; +} + +.taugrid-file code { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; +} + +.taugrid-file .taugrid-muted { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} diff --git a/sdk/python/python/labextension/style/index.js b/sdk/python/python/labextension/style/index.js new file mode 100644 index 00000000..dc8cd6f9 --- /dev/null +++ b/sdk/python/python/labextension/style/index.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import './index.css'; diff --git a/sdk/python/python/labextension/tests/console.test.cjs b/sdk/python/python/labextension/tests/console.test.cjs new file mode 100644 index 00000000..79fa76bd --- /dev/null +++ b/sdk/python/python/labextension/tests/console.test.cjs @@ -0,0 +1,612 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const ts = require('typescript'); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); + +test('loss evidence rejects malformed samples without rejecting lifecycle', () => { + const { parseRunStatus } = load('model.ts'); + const evidence = { state: 'ready', source: { type: 'stdout', runUid: 'run', pod: 'driver', podUid: 'pod', container: 'submitter' }, samples: [{ step: 1, value: 0.5 }], checkedAt: '2026-09-22T00:00:00Z', stale: false, limitBytes: 65536, maxPoints: 512, possiblyTruncated: true, truncationReasons: ['tail-window'], message: 'Bounded evidence' }; + assert.deepEqual(parseRunStatus(run({ metrics: evidence })).metrics, evidence); + for (const samples of [[{ step: -1, value: 1 }], [{ step: 1, value: Infinity }], Array(513).fill({ step: 1, value: 1 })]) { + const status = parseRunStatus(run({ metrics: { ...evidence, samples } })); + assert.equal(status.state, 'running'); + assert.equal(status.metrics.state, 'error'); + assert.deepEqual(status.metrics.samples, []); + } +}); + +test('loss section renders actual points and accessible table only', () => { + const { LossSection } = load('loss.tsx'); + const metrics = { state: 'ready', source: { type: 'stdout', pod: 'driver', podUid: 'pod-uid', container: 'submitter' }, samples: [{ step: 1, value: 0.5 }], checkedAt: '2026-09-22T00:00:00Z', stale: false, possiblyTruncated: true, truncationReasons: ['tail-window'], message: 'Bounded evidence' }; + const html = renderToStaticMarkup(React.createElement(LossSection, { metrics })); + for (const anchor of ['taugrid-loss-curve', 'taugrid-loss-samples', 'taugrid-metrics-source', 'taugrid-metrics-freshness', 'taugrid-metrics-truncation']) assert.ok(html.includes(anchor)); + assert.ok(html.includes(' { + const { RunMonitor } = load('monitor.ts'); + let now = 0; + let response = run(); + const timers = new Map(); + let sequence = 0; + let calls = 0; + const monitor = new RunMonitor(async () => { calls++; return response; }, () => {}, { now: () => now, set: callback => { timers.set(++sequence, callback); return sequence; }, clear: id => timers.delete(id) }, true); + const settle = () => new Promise(resolve => setImmediate(resolve)); + monitor.lookup({ namespace: 'research', kind: 'RayJob', name: 'train' }); + await settle(); + assert.equal(monitor.state.watching, true); + monitor.setWatching(false); + monitor.refresh(); + await settle(); + assert.equal(monitor.state.watching, false); + monitor.setWatching(true); + now = 3600001; + monitor.refresh(); + await settle(); + assert.equal(monitor.state.watching, false); + assert.match(monitor.state.watchMessage, /hour/); + monitor.setWatching(true); + response = run({ terminal: true, state: 'succeeded', metrics: { state: 'empty' } }); + const before = calls; + monitor.refresh(); + await settle(); + assert.equal(calls, before + 1); + assert.equal(monitor.state.status.metrics.state, 'empty'); + assert.equal(monitor.state.watching, false); + assert.equal(timers.size, 0); + monitor.dispose(); +}); + +test('loss curve keeps extreme finite observations finite without inventing points', () => { + const { LossSection } = load('loss.tsx'); + const samples = [{ step: 0, value: -Number.MAX_VALUE }, { step: Number.MAX_SAFE_INTEGER, value: Number.MAX_VALUE }]; + const html = renderToStaticMarkup(React.createElement(LossSection, { metrics: { samples, truncationReasons: [] } })); + assert.equal((html.match(//g) || []).length, 3); + assert.ok(html.includes(' { + const { SubmissionSession } = load('submission.ts'); + const calls = []; + const session = new SubmissionSession(async (path, payload) => { + calls.push({ path, payload }); + return path === 'preview' ? preview : { submitted: true, namespace: plan.namespace, name: plan.name, kind: 'RayJob', payloadDigest: plan.payloadDigest, plan }; + }, () => {}); + await session.review({ notebook: {}, path: 'demo.ipynb', namespace: 'research', profile: 'old', queue: 'old-q' }); + session.invalidate(); + assert.equal(session.state.preview, null); + assert.equal(await session.submit(plan, true, true), false); + await session.review({ notebook: {}, path: 'demo.ipynb', namespace: 'research', profile: 'cpu', queue: 'cpu-q' }); + assert.equal(await session.submit(plan, true, true), true); + assert.equal(calls.at(-1).payload.profile, 'cpu'); + assert.equal(calls.at(-1).payload.queue, 'cpu-q'); + session.dispose(); + const source = fs.readFileSync(path.resolve(__dirname, '../src/submitview.tsx'), 'utf8'); + for (const field of ['profile', 'queue']) { + assert.ok(source.includes(`data-testid="taugrid-submit-${field}"`)); + } +}); + +function load(source, overrides = {}) { + const filename = path.resolve(__dirname, '../src', source); + const output = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.React } + }).outputText; + const module = { exports: {} }; + const resolve = name => overrides[name] || (name.startsWith('.') + ? load(name.slice(2) + (fs.existsSync(path.resolve(__dirname, '../src', name + '.tsx')) ? '.tsx' : '.ts'), overrides) + : require(name)); + new Function('require', 'module', 'exports', output)(resolve, module, module.exports); + return module.exports; +} + +const run = overrides => ({ + existing: true, name: 'train-42', namespace: 'research', state: 'running', + readyPods: 1, totalPods: 2, terminal: false, pods: [], diagnostics: [], ...overrides +}); + +const plan = { name: 'train-reviewed', namespace: 'research', queue: 'gpu', profile: 'small', planDigest: 'a'.repeat(64), payloadDigest: 'digest', notebookBytes: 20, preparedBytes: 10, encodedEnvBytes: 16, excludedCells: [] }; +const preview = { plan, submittable: true, submissionEnabled: true }; +const surfaceOverrides = { './api': {}, '@jupyterlab/apputils': { ReactWidget: class {} } }; + +test('plugin routes discovery left, reuses native tabs and binds notebook toolbar reviews', async () => { + const commands = new Map(); + const added = []; + const toolbar = []; + class SurfaceWidget { + constructor(element) { this.element = element; this.title = {}; this.id = ''; this.isDisposed = false; this.isAttached = false; this.disposed = { connect() {} }; } + } + class WidgetTracker { + constructor() { this.widgets = []; } + async add(widget) { this.widgets.push(widget); } + has(widget) { return this.widgets.includes(widget); } + } + const notebook = { id: 'notebook-1', isDisposed: false, context: { path: 'source.ipynb', model: { toJSON: () => ({ cells: [] }) } }, toolbar: { addItem: (name, button) => toolbar.push({ name, button }) } }; + const notebooks = { currentWidget: notebook, forEach: callback => callback(notebook), widgetAdded: { connect() {} }, currentChanged: { connect() {} } }; + const app = { commands: { addCommand: (name, command) => commands.set(name, command), notifyCommandChanged() {} }, shell: { currentWidget: notebook, currentChanged: { connect() {} }, add: (widget, area) => { widget.isAttached = true; added.push({ widget, area }); }, activateById() {} } }; + const restored = []; + const restorer = { add: (widget, name) => restored.push({ widget, name }), restore: (tracker, options) => { restored.push({ tracker, options }); return Promise.resolve(); } }; + const plugin = load('index.ts', { + '@jupyterlab/application': {}, '@jupyterlab/launcher': {}, '@jupyterlab/notebook': {}, + '@jupyterlab/apputils': { WidgetTracker, ToolbarButton: class { constructor(options) { Object.assign(this, options); } }, Dialog: { cancelButton() {}, okButton() {} }, showDialog: async () => ({ button: { accept: false } }), ReactWidget: { create: element => element } }, + './widget': { SurfaceWidget, RunsSidebar() {}, RunDetail() {}, RunLogs() {}, About() {}, runWidgetId: target => JSON.stringify(target) }, + './submitview': { SubmissionReview() {}, SubmissionConfirmation() {} } + }).default; + plugin.activate(app, restorer, { add() {} }, { addItem() {} }, notebooks); + await commands.get('taugrid:open').execute(); + assert.equal(added[0].area, 'left'); + assert.equal(added[0].widget.id, 'taugrid-runs'); + const target = { namespace: 'research', kind: 'RayJob', name: 'train' }; + await commands.get('taugrid:open-run').execute(target); + await commands.get('taugrid:open-run').execute(target); + assert.equal(added.filter(item => item.area === 'main').length, 1); + assert.equal(toolbar[0].name, 'taugrid-submit'); + assert.equal(commands.get('taugrid:submit-notebook').isEnabled(), true); + app.shell.currentWidget = added[1].widget; + assert.equal(commands.get('taugrid:submit-notebook').isEnabled(), false); + await toolbar[0].button.onClick(); + const review = added.at(-1).widget; + assert.equal(review.element.props.notebook.name, 'source.ipynb'); + notebooks.currentWidget = { context: { path: 'other.ipynb' } }; + assert.equal(review.element.props.notebook.toJSON(), '{"cells":[]}'); + assert.equal(restored.filter(item => item.options).length, 1); +}); + +test('sidebar owns discovery and lookup, never monitoring or submission', () => { + const { RunsSidebar } = load('widget.tsx', surfaceOverrides); + const html = renderToStaticMarkup(React.createElement(RunsSidebar, { onOpenRun() {}, onAbout() {} })); + for (const text of ['taugrid-runs', 'Namespace', 'LocalQueue filter', 'Refresh', 'Find exact run', 'Kind', 'RayJob', 'Check run', 'About']) assert.ok(html.includes(text), text); + // The namespace is a filterable selection, not a free-text guess. + assert.ok(html.includes('list="'), 'namespace input must offer a filtered list'); + assert.ok(html.includes(' { + const { RunDetail, RunLogs, runWidgetId } = load('widget.tsx', surfaceOverrides); + const target = { namespace: 'research', name: 'train', kind: 'Job' }; + const detail = renderToStaticMarkup(React.createElement(RunDetail, { target, onOpenLogs() {} })); + assert.ok(detail.includes('taugrid-detail')); + assert.ok(detail.includes('Refresh run')); + assert.ok(!detail.includes('List runs')); + assert.ok(!detail.includes('Submit notebook')); + const logs = renderToStaticMarkup(React.createElement(RunLogs, { target })); + assert.ok(logs.includes('taugrid-logs')); + assert.ok(logs.includes('Refresh pods')); + assert.notEqual(runWidgetId(target), runWidgetId({ ...target, kind: 'RayJob' })); + assert.notEqual(runWidgetId(target), runWidgetId({ ...target, namespace: 'another' })); + assert.notEqual(runWidgetId(target), runWidgetId(target, 'logs')); +}); + +test('submission review owns plan and source; confirmation names the exact write', () => { + const { SubmissionReview, SubmissionConfirmation } = load('submitview.tsx', surfaceOverrides); + const html = renderToStaticMarkup(React.createElement(SubmissionReview, { notebook: { name: 'source.ipynb', toJSON: () => '{}' }, onConfirm: async () => false, onOpenRun() {}, onAbout() {} })); + for (const text of ['taugrid-review', 'source.ipynb', 'Review submission', 'Submit notebook']) assert.ok(html.includes(text), text); + assert.ok(!html.includes('List runs')); + assert.ok(!html.includes('Watch this run')); + const confirmation = renderToStaticMarkup(React.createElement(SubmissionConfirmation, { plan })); + for (const text of ['research', 'train-reviewed', 'gpu', 'reviewed notebook snapshot']) assert.ok(confirmation.includes(text), text); +}); + +test('submission commits immutable reviewed bytes only after confirmation and both gates', async () => { + const { SubmissionSession } = load('submission.ts'); + const calls = []; + const session = new SubmissionSession(async (path, body) => { calls.push({ path, body }); return path === 'preview' ? preview : { submitted: true, name: plan.name, namespace: plan.namespace, kind: 'RayJob', payloadDigest: plan.payloadDigest, plan }; }, () => {}); + const source = { notebook: 'original', path: 'source.ipynb', namespace: 'research' }; + await session.review(source); + source.notebook = 'edited'; + assert.equal(await session.submit(plan, false, true), false); + assert.equal(await session.submit(plan, true, false), false); + assert.equal(calls.length, 1); + assert.equal(await session.submit(plan, true, true), true); + assert.equal(calls[1].body.notebook, 'original'); + assert.equal(calls[1].body.name, plan.name); + assert.equal(calls[1].body.confirm, true); + assert.equal(await session.submit(plan, true, true), false); + assert.equal(calls.length, 2); +}); + +test('preview gate, invalidation and stale confirmations cannot submit', async () => { + const { SubmissionSession } = load('submission.ts'); + let enabled = false; + const calls = []; + const session = new SubmissionSession(async (path) => { calls.push(path); return { ...preview, submittable: enabled }; }, () => {}); + await session.review({ notebook: '{}', path: 'source.ipynb', namespace: 'research' }); + assert.equal(await session.submit(plan, true, true), false); + enabled = true; + await session.review({ notebook: '{}', path: 'source.ipynb', namespace: 'research' }); + session.invalidate(); + assert.equal(await session.submit(plan, true, true), false); + assert.deepEqual(calls, ['preview', 'preview']); +}); + +test('replaced/disposed reviews abort and discard late results', async () => { + const { SubmissionSession } = load('submission.ts'); + const pending = []; + const states = []; + const session = new SubmissionSession((path, body, signal) => new Promise(resolve => pending.push({ signal, resolve })), state => states.push(state)); + const first = session.review({ notebook: 'old', path: 'old.ipynb', namespace: 'ray' }); + const second = session.review({ notebook: 'new', path: 'new.ipynb', namespace: 'ray' }); + assert.equal(pending[0].signal.aborted, true); + pending[0].resolve(preview); + await first; + assert.equal(session.state.preview, null); + session.dispose(); + const count = states.length; + assert.equal(pending[1].signal.aborted, true); + pending[1].resolve(preview); + await second; + assert.equal(states.length, count); +}); + +test('failed writes expose uncertain outcome and cannot be retried without review', async () => { + const { SubmissionSession } = load('submission.ts'); + const session = new SubmissionSession(async path => { if (path === 'preview') return preview; throw new Error('timeout'); }, () => {}); + await session.review({ notebook: '{}', path: 'source.ipynb', namespace: 'ray' }); + assert.equal(await session.submit(plan, true, true), false); + assert.equal(session.state.uncertain, true); + assert.equal(session.state.preview, null); + assert.deepEqual(session.state.attempted, { namespace: plan.namespace, name: plan.name, kind: 'RayJob' }); + assert.equal(await session.submit(plan, true, true), false); +}); + +test('copy distinguishes admission, execution, missing and unreachable', () => { + const { describeRun } = load('model.ts'); + assert.equal(describeRun(run({ state: 'queued', admitted: false })).label, 'Waiting for admission'); + assert.equal(describeRun(run({ state: 'queued', admitted: true })).label, 'Queued'); + assert.equal(describeRun(run({ state: 'not_submitted', existing: false })).label, 'No such run'); + assert.equal(describeRun(run({ state: 'unknown', existing: false })).label, 'Unreachable'); + assert.equal(describeRun(run({ state: 'complete' })).label, 'Finished'); + assert.match(describeRun(run()).detail, /not.*training health/i); +}); + +test('render includes identifiers, unknown admission, diagnostics and link-out', () => { + const { RunDetails } = load('view.tsx'); + const html = renderToStaticMarkup(React.createElement(RunDetails, { + status: run({ jobId: 'job-17', rayClusterName: 'cluster-17', diagnostics: [ + { code: 'restart', severity: 'warning', message: 'Worker restarted', suggestion: 'Inspect worker logs.' } + ], pods: [{ name: 'worker-1', ready: false, restarts: 3, phase: 'Running', node: 'gpu-1' }] }), + stale: false, statusUrl: '/user/alice/taugrid/api/status?name=train-42' + })); + for (const text of ['taugrid-run', 'train-42', 'job-17', 'cluster-17', 'Not reported', + 'Worker restarted', 'Inspect worker logs.', '3 restarts', 'Not ready', 'Open status JSON']) { + assert.ok(html.includes(text), text); + } + assert.ok(html.includes('/user/alice/taugrid/api/status?name=train-42')); +}); + +test('stale data is not presented as a current running state', () => { + const { RunDetails } = load('view.tsx'); + const html = renderToStaticMarkup(React.createElement(RunDetails, { + status: run(), stale: true, statusUrl: '/status' + })); + assert.ok(html.includes('Status unavailable')); + assert.ok(html.includes('Last known state: Running')); +}); + +function harness() { + const { RunMonitor } = load('monitor.ts'); + const pending = []; + const timers = new Map(); + let nextTimer = 0; + const monitor = new RunMonitor((target, signal) => new Promise((resolve, reject) => { + pending.push({ target, signal, resolve, reject }); + }), () => {}, { + set: callback => { timers.set(++nextTimer, callback); return nextTimer; }, + clear: handle => timers.delete(handle), now: () => 1234 + }); + return { monitor, pending, timers }; +} + +const settle = () => new Promise(resolve => setImmediate(resolve)); + +test('browser and logs render labeled native controls and partial-list warnings', () => { + const { RunBrowser, RunRows, LogViewer, LogContent } = load('explorer.tsx', { './api': {} }); + const browser = renderToStaticMarkup(React.createElement(RunBrowser, { namespace: 'ray', onSelect: () => {}, portalUrl: null })); + // The list loads itself; the only control is an explicit refresh. + for (const text of ['Live runs', 'LocalQueue filter', 'Refresh', 'durable history']) { assert.ok(browser.includes(text), text); } + assert.ok(!browser.includes('List runs')); + const rows = renderToStaticMarkup(React.createElement(RunRows, { result: { + runs: [{ namespace: 'ray', name: 'train', kind: 'Job', state: 'queued' }, { namespace: 'ray', name: 'train', kind: 'RayJob', state: 'Running' }], + warnings: ['RayJob read denied'], truncated: true }, onSelect: () => {} })); + for (const text of ['Job', 'RayJob', 'RayJob read denied', 'partial', 'train']) { assert.ok(rows.includes(text), text); } + // Rows are a table, not a card list. + for (const text of ['alert(1)', limitBytes: 65536, possiblyTruncated: true } })); + assert.ok(content.includes('<script>')); + assert.ok(content.includes('truncated')); + assert.ok(!content.includes('" + ) + return "".join(parts) + + +@dataclass +class EmbedView: + """The result of an embed: the framed URL plus a direct portal link.""" + + url: str + path: str + portal_url: str + html: str + proxied: bool = False + + def _repr_html_(self) -> str: + return self.html + + def __str__(self) -> str: + return self.url + + +class PortalProxy: + """A local reverse proxy that serves the TauGrid portal on a separate loopback origin. + + Starts a ThreadingHTTPServer on 127.0.0.1 and forwards every request to the + portal base URL, dropping X-Frame-Options so the notebook can frame it. The + portal SPA uses root-absolute asset and API paths, so a transparent proxy + works without HTML rewriting. + """ + + def __init__( + self, + portal_url: str, + *, + host: str = "127.0.0.1", + port: int = 0, + headers: Optional[Dict[str, str]] = None, + timeout: float = 30.0, + ) -> None: + self.portal_url = portal_url.rstrip("/") + self.host = host + self._requested_port = port + self.headers = dict(headers or {}) + self.timeout = timeout + self._server: Optional[ThreadingHTTPServer] = None + self._thread: Optional[threading.Thread] = None + + @property + def port(self) -> int: + if self._server is None: + return self._requested_port + return int(self._server.server_address[1]) + + @property + def base_url(self) -> str: + return f"http://{self.host}:{self.port}" + + def url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + return self.base_url + path + + def start(self) -> "PortalProxy": + if self._server is not None: + return self + handler = _make_handler(self.portal_url, self.headers, self.timeout) + self._server = ThreadingHTTPServer((self.host, self._requested_port), handler) + self._server.daemon_threads = True # type: ignore[attr-defined] + self._thread = threading.Thread(target=self._server.serve_forever, name="tau-portal-proxy", daemon=True) + self._thread.start() + return self + + def stop(self) -> None: + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None + + +def _strip_frame_ancestors(csp: str) -> str: + """Remove frame-ancestors for the opt-in legacy embed.""" + directives = [d.strip() for d in csp.split(";") if d.strip()] + kept = [d for d in directives if not d.lower().startswith("frame-ancestors")] + return "; ".join(kept) + + +def _make_handler(portal_url: str, headers: Dict[str, str], timeout: float) -> type: + class _Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 - http.server API + self._forward("GET") + + def do_HEAD(self) -> None: # noqa: N802 - http.server API + self._forward("HEAD") + + def _forward(self, method: str) -> None: + target = portal_url + self.path + request = urllib.request.Request(target, method=method, headers=dict(headers)) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = response.read() + self._relay(response.status, response.headers.items(), body, method) + except urllib.error.HTTPError as exc: + body = exc.read() + self._relay(exc.code, exc.headers.items() if exc.headers else [], body, method) + except Exception as exc: # pragma: no cover - network failure path + message = f"TauGrid portal proxy could not reach {target}: {exc}".encode("utf-8") + self.send_response(502) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(message))) + self.end_headers() + if method != "HEAD": + self.wfile.write(message) + + def _relay(self, status: int, header_items: Any, body: bytes, method: str) -> None: + self.send_response(status) + for key, value in header_items: + lower = key.lower() + if lower in _HOP_BY_HOP or lower in _FRAME_HEADERS: + continue + if lower == "content-security-policy": + value = _strip_frame_ancestors(value) + if lower == "content-length": + continue + self.send_header(key, value) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if method != "HEAD": + self.wfile.write(body) + + def log_message(self, *args: Any) -> None: # keep the notebook output clean + return + + return _Handler + + +__all__ = [ + "EmbedView", + "PortalProxy", + "run_view_path", + "ray_dashboard_path", + "experiment_path", + "same_origin", + "iframe_html", +] diff --git a/sdk/python/python/tau/widgets/ipython.py b/sdk/python/python/tau/widgets/ipython.py new file mode 100644 index 00000000..08579993 --- /dev/null +++ b/sdk/python/python/tau/widgets/ipython.py @@ -0,0 +1,121 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""IPython/Jupyter plugin loading for the TauGrid notebook panel. + +A platform-authored bootstrap cell loads the plugin without the end user ever +writing an import: IPython auto-loads the load_ipython_extension below when it +is registered (e.g. via %load_ext tau.widgets.ipython) and the plugin +self-registers a %taugrid line magic. + +Two modes, mirroring the TensorBoard notebook plugin: + + %load_ext tau.widgets.ipython + %taugrid # interactive widget panel + %taugrid name=my-rayjob namespace=ray # attach to an existing run + %taugrid --embed --name=my-rayjob --namespace=ray --portal=http://portal:8080 + +The --embed mode serves the TauGrid portal run view through a separate loopback-origin +proxy and frames it in the cell output, so the notebook shows the same UI as the +portal (the TensorBoard pattern). +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Set, Tuple + +_EMBED_KEYS = ("target", "height", "portal", "page_origin", "workspace") + + +def _parse_magic(line: str) -> Tuple[Set[str], Dict[str, str]]: + """Parse a magic argument line into (flags, kwargs). + + Accepts both the TensorBoard-style space-separated form + (--embed --name=x --namespace=y) and the comma-separated form + (name=x, namespace=y). + """ + flags: Set[str] = set() + kwargs: Dict[str, str] = {} + if not line: + return flags, kwargs + for raw in re.split(r"[,\s]+", line.strip()): + token = raw.strip() + if not token: + continue + if token.startswith("--"): + token = token[2:] + if "=" in token: + key, _, value = token.partition("=") + kwargs[key.strip()] = value.strip().strip("'\"") + else: + flags.add(token.strip()) + elif "=" in token: + key, _, value = token.partition("=") + kwargs[key.strip()] = value.strip().strip("'\"") + return flags, kwargs + + +def _truthy(value: Any) -> bool: + return str(value).strip().lower() in ("1", "true", "yes", "on") + + +def load_ipython_extension(ipython: Any) -> None: # pragma: no cover - ipython + """Register the %taugrid line magic on the running IPython instance.""" + from IPython.core.magic import Magics, line_magic, magics_class # type: ignore[import-not-found] + + from tau.widgets.panel import panel + + @magics_class + class _TauWidgetMagics(Magics): + @line_magic + def taugrid(self, line: str) -> Any: + flags, kwargs = _parse_magic(line) + + # name= attaches to an existing run; it is not a constructor arg. + run_name = kwargs.pop("name", None) + if "portal" in kwargs: + kwargs["portal_url"] = kwargs.pop("portal") + + embed = "embed" in flags or _truthy(kwargs.pop("embed", "false")) + embed_opts: Dict[str, Any] = {} + if embed: + for key in _EMBED_KEYS: + if key in kwargs: + embed_opts[key] = kwargs.pop(key) + if "portal" in embed_opts: + embed_opts["portal_url"] = embed_opts.pop("portal") + if "height" in embed_opts: + embed_opts["height"] = int(embed_opts["height"]) + + ctrl = panel(**kwargs) # type: ignore[call-arg] + if run_name: + ctrl.load(str(run_name)) + + if embed: + # TensorBoard pattern: the cell output is the framed portal UI. + return ctrl.embed(**embed_opts) + + # Return the panel, not a string: Jupyter renders its + # _repr_html_ as text/html, so the browser gets real SVG and class + # hooks instead of an escaped text repr. + return ctrl + + @line_magic + def tau(self, line: str) -> Any: # compatibility alias + return self.taugrid(line) + + ipython.register_magics(_TauWidgetMagics) + + +def unload_ipython_extension(ipython: Any) -> None: # pragma: no cover - ipython + """Remove the %taugrid magic when the extension is unloaded.""" + try: + from tau.widgets.panel import TauGridPanel # noqa: F401 + + del TauGridPanel + except Exception: + pass + + +__all__ = ["load_ipython_extension", "unload_ipython_extension"] diff --git a/sdk/python/python/tau/widgets/kube.py b/sdk/python/python/tau/widgets/kube.py new file mode 100644 index 00000000..5e6a3e1f --- /dev/null +++ b/sdk/python/python/tau/widgets/kube.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Kubernetes client access for the notebook widget. + +The client is lazily built from kubeconfig / in-cluster config, and is +injectable so the panel is testable offline with fakes (no cluster, no network). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class ClusterClient: + """Bundle of the two Kubernetes API surfaces the widget uses. + + ``custom`` is a ``CustomObjectsApi``-shaped object; ``core`` is a + ``CoreV1Api``-shaped object. Each is injectable for offline tests. + """ + + custom: Any + core: Any + batch: Any = None + owned_api_client: Any = None + + def close(self): + if self.owned_api_client is not None: + self.owned_api_client.close() + + +def load_client(*, core: Any = None, custom: Any = None, batch: Any = None, retries: int | None = None) -> ClusterClient: + """Build a ``ClusterClient`` from runtime config or the injected fakes. + + When ``core``/``custom`` are provided they are used as-is (offline tests pass + fakes here). Otherwise the ``kubernetes`` client config is loaded lazily. + """ + if core is not None and custom is not None: + return ClusterClient(custom=custom, core=core, batch=batch) + + from kubernetes import client, config # lazy: only when a real cluster is used + + try: + config.load_incluster_config() + except Exception: # pragma: no cover - exercised when NOT in a cluster + try: + config.load_kube_config() + except Exception: # pragma: no cover - no config available + config.load_config() + + configuration = client.Configuration.get_default_copy() + if retries is not None: + configuration.retries = retries + api = client.ApiClient(configuration) + return ClusterClient(custom=client.CustomObjectsApi(api), core=client.CoreV1Api(api), batch=client.BatchV1Api(api), owned_api_client=api) + + +__all__ = ["ClusterClient", "load_client"] diff --git a/sdk/python/python/tau/widgets/metrics.py b/sdk/python/python/tau/widgets/metrics.py new file mode 100644 index 00000000..071ca8f5 --- /dev/null +++ b/sdk/python/python/tau/widgets/metrics.py @@ -0,0 +1,152 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Loss/metrics sources for the notebook widget. + +Three adapters map to the design's loss-source chain (metrics file -> stdout -> +portal series). Every adapter is a pure function of its inputs or takes an +injectable ``opener``, so the whole surface is testable offline. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional +from urllib.parse import urlencode + +_TAG_RE = re.compile(r"^\s*(?:step\s*=\s*(\d+)\s+)?loss\s*=\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*$") + + +@dataclass(frozen=True) +class MetricSample: + step: int + value: float + + +@dataclass +class MetricSeries: + """Loss series for the panel; empty when no samples yet.""" + + samples: List[MetricSample] = None # type: ignore[assignment] + + def __post_init__(self) -> None: + if self.samples is None: + object.__setattr__(self, "samples", []) + + @property + def has_data(self) -> bool: + return len(self.samples) >= 1 + + @property + def first(self) -> Optional[MetricSample]: + return self.samples[0] if self.samples else None + + @property + def last(self) -> Optional[MetricSample]: + return self.samples[-1] if self.samples else None + + +def parse_stdout_line(line: str) -> Optional[MetricSample]: + """Parse a ``loss`` / ``step=.. loss=..`` line, or None if it does not match.""" + match = _TAG_RE.match(line) + if not match: + return None + step_text, loss_text = match.groups() + step = int(step_text) if step_text is not None else 0 + value = float(loss_text) + return MetricSample(step=step, value=value) + + +def read_metrics_file(path: Any) -> MetricSeries: + """Parse JSONL at ```` path; malformed lines are skipped. + + Each object is ``{"step": int, "loss": float}``; non-numeric entries are + skipped rather than failing the whole stream. + """ + series = MetricSeries() + with open(str(path), "r", encoding="utf-8") as handle: + for raw in handle: + raw = raw.strip() + if not raw: + continue + try: + record = json.loads(raw) + except json.JSONDecodeError: + continue + try: + step = int(record["step"]) + value = float(record["loss"]) + except (KeyError, TypeError, ValueError): + continue + series.samples.append(MetricSample(step=step, value=value)) + return series + + +def parse_stdout(stream: Any) -> MetricSeries: + """Collect loss samples from any iterable of lines using the stdout convention.""" + series = MetricSeries() + for line in stream: + sample = parse_stdout_line(line) + if sample is not None: + series.samples.append(sample) + return series + + +class StellarSeriesClient: + """Reads the portal ``/api/stellar/series`` endpoint through an injectable opener.""" + + def __init__(self, base_url: str, opener: Optional[Callable[[str], Any]] = None) -> None: + self.base_url = base_url.rstrip("/") + self._opener = opener or _default_urlopen + + def series(self, *, target: str, metric: str = "train/loss", max_points: int = 1000) -> MetricSeries: + params = urlencode({"target": target, "metric": metric, "max_points": max_points}) + url = f"{self.base_url}/api/stellar/series?{params}" + body = self._opener(url) + return _series_from_payload(body) + + +def _series_from_payload(payload: Any) -> MetricSeries: + if isinstance(payload, str): + try: + payload = json.loads(payload) + except json.JSONDecodeError: + return MetricSeries() + chart = (payload or {}).get("chart") or {} + series = MetricSeries() + for s in chart.get("series") or []: + for point in s.get("values") or []: + try: + series.samples.append(MetricSample(step=int(point["step"]), value=float(point["value"]))) + except (KeyError, TypeError, ValueError): + continue + return series + + +def _default_urlopen(url: str) -> Any: # pragma: no cover - exercised only against a real portal + import urllib.request + + with urllib.request.urlopen(url) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def loss_delta(series: MetricSeries) -> Dict[str, Optional[Any]]: + """Summarize a series the way the panel hero renders it.""" + if not series.has_data: + return {"direction": None, "percent": None, "first": None, "last": None} + first = series.first + last = series.last + if first is None or last is None or first.value == 0: + return {"direction": None, "percent": None, "first": first, "last": last} + change = (last.value - first.value) / abs(first.value) + return { + "direction": "down" if change < 0 else ("up" if change > 0 else "flat"), + "percent": abs(change) * 100, + "first": first, + "last": last, + } + + +__all__ = ["MetricSeries", "MetricSample", "read_metrics_file", "parse_stdout", "parse_stdout_line", "StellarSeriesClient", "loss_delta"] \ No newline at end of file diff --git a/sdk/python/python/tau/widgets/panel.py b/sdk/python/python/tau/widgets/panel.py new file mode 100644 index 00000000..f10ffc71 --- /dev/null +++ b/sdk/python/python/tau/widgets/panel.py @@ -0,0 +1,445 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""The ipywidgets panel for the notebook plugin. + +TauGridPanel builds the widget; panel() is the convenience entry point a +platform-authored template cell calls. ipywidgets (and IPython) are imported +lazily so a plain import tau never requires them; on hosts without a widget +manager the panel degrades to a plain-HTML render() snapshot. + +The panel supports two run flows: + +* submit the current notebook as a Kueue-admitted RayJob, and +* load an existing RayJob from the cluster by name and check its status. + +Both are read-only with respect to any run the panel did not create: load and +watch never mutate or delete cluster resources. +""" + +from __future__ import annotations + +import html +from pathlib import Path +from typing import Any, Optional + +from tau._backend import KubernetesBackend +from tau._notebook_pkg import MAX_INPUT_BYTES, StagedPayload +from tau._notebook_submit import build_plan, submit_plan +from tau._render import Profile +from tau.widgets.embed import EmbedView, PortalProxy, experiment_path, iframe_html, ray_dashboard_path, run_view_path, same_origin +from tau.widgets.kube import ClusterClient +from tau.widgets.metrics import MetricSeries +from tau.widgets.render import diagnostics_html, loss_delta_text, status_header_html, svg_loss_chart +from tau.widgets.status import RunStatus, list_runs, read_run_status +from tau.widgets.watcher import DEFAULT_INTERVAL, StatusWatcher + + +class TauGridPanel: + """ipywidgets layout shell; render() is UI-free and testable. + + client and opener are injected so the panel works and is testable without a + cluster. The interactive widget tree is built lazily by build() only when a + display is actually needed. + """ + + def __init__( + self, + *, + namespace: str = "ray", + run_name: str = "", + portal_url: str = "https://portal.contoso.com", + client: Optional[ClusterClient] = None, + ) -> None: + self.namespace = namespace + self.run_name = run_name + self.portal_url = portal_url.rstrip("/") + self.client = client + self.loss: MetricSeries = MetricSeries() + self.staged: Optional[StagedPayload] = None + self.manifest: Optional[Any] = None + self.notebook_path: Optional[str] = None # manual override wins (design 4.1) + self.status: Optional[RunStatus] = None + self.watcher: Optional[StatusWatcher] = None + self.embed_view: Optional[EmbedView] = None + self._proxy: Optional[PortalProxy] = None + self._controls: Optional[Any] = None + + # -- notebook identity (design 4.1: manual override is authoritative) -- + def set_notebook_path(self, path: Optional[str]) -> None: + """Set a manual notebook path override (visible and authoritative).""" + self.notebook_path = path + # A built tree must reflect the override immediately: the Submit button + # enables once a notebook identity is known (S005). + self._repaint_controls() + + # -- load an existing run from the cluster (read-only) -- + def load(self, name: str, namespace: Optional[str] = None, *, client: Optional[ClusterClient] = None) -> RunStatus: + """Attach the panel to an existing RayJob and read its status. + + This is the "load job from the cluster" path: it performs a single + read-only get and switches the panel into run view. It never creates, + mutates, or deletes a resource. + """ + if namespace: + self.namespace = namespace + if client is not None: + self.client = client + self.run_name = name + self.status = self._read_status() + self._repaint_controls() + return self.status if self.status is not None else RunStatus(name=name, namespace=self.namespace) + + #: Alias that reads better at call sites that just want the current state. + def check_status(self) -> Optional[RunStatus]: + """Re-read the attached run's status and return the normalized result.""" + return self.refresh_status() + + def list_runs(self, *, label_selector: Optional[str] = None) -> list: + """List RayJobs in the panel's namespace (for a run chooser).""" + if self.client is None: + return [] + return list_runs(self.client, namespace=self.namespace, label_selector=label_selector) + + # -- TensorBoard-style inline embed of the portal UI -- + def embed( + self, + *, + target: str = "run", + height: int = 640, + portal_url: Optional[str] = None, + page_origin: Optional[str] = None, + workspace: Optional[str] = None, + ) -> EmbedView: + """Embed the TauGrid portal UI inline in the notebook cell. + + This legacy kernel-identity helper removes upstream frame restrictions + through a separate loopback origin. It is not same-origin with Jupyter; + use it only with a trusted local kernel and portal. + + target selects the portal view: "run" (run detail), "ray" (the run's Ray + dashboard, needs a resolved ray_cluster_name), or "experiments" (the + Stellar view for the run's job id). Returns an EmbedView whose HTML is a + framed view plus a direct portal link fallback. + """ + portal = (portal_url or self.portal_url).rstrip("/") + path = self._embed_path(target, workspace=workspace) + direct = portal + path + + # Direct frames require matching origins; otherwise use the opt-in proxy. + use_proxy = not (page_origin and same_origin(page_origin, portal)) + if use_proxy: + if self._proxy is None: + self._proxy = PortalProxy(portal).start() + url = self._proxy.url(path) + else: + url = direct + + view = EmbedView( + url=url, + path=path, + portal_url=portal, + html=iframe_html(url, height=height, link=direct), + proxied=use_proxy, + ) + self.embed_view = view + return view + + def _embed_path(self, target: str, *, workspace: Optional[str] = None) -> str: + if target == "run": + if not self.run_name: + raise ValueError("embed(target='run') needs a run name: call load() or pass run_name=") + path = run_view_path(self.namespace, self.run_name) + elif target == "ray": + cluster = self.status.ray_cluster_name if self.status else None + if not cluster: + raise ValueError( + "embed(target='ray') needs a resolved Ray cluster: call load() first so the " + "RayJob status reports rayClusterName" + ) + path = ray_dashboard_path(self.namespace, cluster) + elif target == "experiments": + run_id = self.status.job_id if self.status else None + if not run_id: + raise ValueError("embed(target='experiments') needs a job id: call load() first") + path = experiment_path(run_id) + else: + raise ValueError(f"unknown embed target {target!r}; expected run, ray, or experiments") + if workspace: + joiner = "&" if "?" in path else "?" + path = f"{path}{joiner}workspace={workspace}" + return path + + def close(self) -> None: + """Stop the embed proxy and the status watcher, if either is running.""" + self.stop_watching() + if self._proxy is not None: + self._proxy.stop() + self._proxy = None + + # -- data (pure, offline-testable) -- + def refresh_status(self) -> Optional[RunStatus]: + """Re-read status for the attached run; None when no client is set.""" + self.status = self._read_status() + return self.status + + def _read_status(self) -> Optional[RunStatus]: + if self.client is None: + return None + if not self.run_name: + return None + return read_run_status(self.client, namespace=self.namespace, name=self.run_name) + + def set_loss(self, series: MetricSeries) -> None: + self.loss = series + + # -- watch (single-flight poll loop) -- + def watch(self, *, interval: float = DEFAULT_INTERVAL) -> Optional[StatusWatcher]: + """Start polling the attached run's status until it is terminal. + + Returns the watcher, or None when there is no client/run to watch. The + watcher is generation-tagged, so stop_watching() discards late replies. + """ + if self.client is None or not self.run_name: + return None + self.stop_watching() + self.watcher = StatusWatcher(self._read_status, on_update=self._on_watch_update, interval=interval) + self.watcher.start() + return self.watcher + + def stop_watching(self) -> None: + """Stop polling. Never cancels or deletes the run.""" + if self.watcher is not None: + self.watcher.stop() + self.watcher = None + + def _on_watch_update(self, status: Optional[RunStatus]) -> None: + if status is not None: + self.status = status + self._repaint_controls() + + # -- submit (design 5.2: the panel IS the submit path) -- + def submit( + self, + notebook: Optional[str] = None, + *, + notebook_bytes: Optional[bytes] = None, + cluster: Optional[Any] = None, + profile: Optional[Profile] = None, + queue: Optional[str] = None, + runtime_pip: Optional[list] = None, + backend: Optional[KubernetesBackend] = None, + staging_dir: Optional[Any] = None, + env: Optional[dict] = None, + env_secret: Optional[dict] = None, + input_cap: int = MAX_INPUT_BYTES, + ) -> Any: + """Submit the current notebook as a Kueue-admitted RayJob. + + The full chain, each step injectable so tests run offline with fakes: + + 1. package the notebook bytes into a self-contained staged payload + (_notebook_pkg.package); + 2. resolve the worker profile and Kueue queue (_profile.resolve from the + TauCluster CRD, or the explicit args); + 3. render the constrained ray.io/v1 RayJob (_render.render_rayjob): + spec.suspend true, managedBy absent, queue label, control-only head, + worker sizing from the profile; + 4. apply it through KubernetesBackend (no tau binary, no kubectl), then + switch the panel into run view. + + Returns the SubmittedRun-shaped handle. + """ + bytes_in = notebook_bytes + if bytes_in is None: + path = notebook or self.run_name or "" + if not path: + raise ValueError( + "no notebook to submit: resolve the current notebook from " + "the Jupyter session or pass notebook=/notebook_bytes=" + ) + with Path(path).open("rb") as source: + bytes_in = source.read(input_cap + 1) + + plan = build_plan( + client=self.client, notebook_bytes=bytes_in, namespace=self.namespace, + name=self.run_name or "analysis", profile=profile, queue=queue, cluster=cluster, + pip=runtime_pip, env=env, env_secret=env_secret, input_cap=input_cap, + staging_dir=staging_dir or self._default_staging_dir(), + ) + handle = submit_plan(client=self.client, plan=plan, backend=backend) + self.run_name = plan.name + self.staged = plan.staged + self.manifest = plan.manifest + self.status = RunStatus( + existing=True, + name=plan.name, + namespace=self.namespace, + state="queued", + display_state="queued", + queue=plan.queue, + ) + self._repaint_controls() + return handle + + def _default_staging_dir(self) -> Any: + import tempfile + + return Path(tempfile.mkdtemp(prefix="tau-notebook-")) + + # -- output -- + def render(self) -> str: + """A plain-HTML render of the panel (used for Save HTML export).""" + parts = [ + "
", + self._status_html(), + f"
{loss_delta_text(self.loss)}
", + svg_loss_chart(self.loss), + ] + if self.status is not None: + parts.append(diagnostics_html(self.status)) + parts.append("
") + return "".join(parts) + + def _repr_html_(self) -> str: + """The notebook display convention: Jupyter renders this as real HTML. + + Returning the panel itself from %taugrid (rather than render()'s plain + string) makes JupyterLab present the panel as text/html, so the browser + renders the SVG and class hooks as DOM nodes. + """ + return self.render() + + # -- interactive widget tree (the button in the notebook) -- + def build(self) -> Any: + """Build the interactive panel: Submit/Load/Refresh buttons, status, chart. + + Requires ipywidgets (the tau[widgets] extra). The tree is constructible + without a frontend, so its wiring is unit-testable offline; the widget + manager only enters the picture on display. + """ + import ipywidgets as widgets # lazy: only when a real button is wanted + + if self.client is not None and self.run_name: + self.refresh_status() + + submit_button = widgets.Button( + description="Submit notebook", + button_style="primary", + tooltip="Package this notebook and submit it as a Kueue-admitted RayJob", + disabled=self._submit_disabled(), + ) + run_input = widgets.Text( + value=self.run_name, + description="Run name", + placeholder="existing RayJob name", + ) + load_button = widgets.Button(description="Load run", tooltip="Load an existing RayJob and check its status") + refresh_button = widgets.Button(description="Refresh", tooltip="Re-read run status") + status = widgets.HTML(self._status_html()) + chart = widgets.HTML(self.render()) + + submit_button.on_click(self._on_submit_clicked) + load_button.on_click(self._on_load_clicked) + refresh_button.on_click(self._on_refresh_clicked) + + self._controls = { + "submit": submit_button, + "load": load_button, + "run_input": run_input, + "refresh": refresh_button, + "status": status, + "chart": chart, + } + return widgets.VBox( + [ + status, + widgets.HBox([submit_button, refresh_button]), + widgets.HBox([run_input, load_button]), + chart, + ], + layout=widgets.Layout(border="1px solid #e2e8f0", padding="8px"), + ) + + def _submit_disabled(self) -> bool: + """The Submit button is disabled until a notebook path is known (S005).""" + return not bool(self.notebook_path) + + def _status_html(self) -> str: + """One-line state header: run identity plus run state (design 6.3).""" + if self.status is not None and self.status.existing: + return status_header_html(self.status) + if not self.notebook_path: + return "
notebook path not resolved
" + if not self.run_name: + return f"
not submitted - {html.escape(str(self.notebook_path))}
" + state = self.status.display_state if self.status is not None else "unknown" + return f"
{html.escape(self.run_name)}: {html.escape(str(state))} - {html.escape(str(self.notebook_path))}
" + + def _repaint_controls(self) -> None: + if self._controls is None: + return + self._controls["submit"].disabled = self._submit_disabled() + self._controls["status"].value = self._status_html() + self._controls["chart"].value = self.render() + + def _on_submit_clicked(self, _change: Any = None) -> Any: + """The Submit button's action: run the full chain, then repaint status. + + The button's on_click registers this handler; tests call it directly to + exercise the same path a user click drives. + """ + if self._submit_disabled(): + raise ValueError( + "no notebook to submit: resolve it from the Jupyter session or " + "set a manual path with panel.set_notebook_path(...)" + ) + handle = self.submit(notebook=self.notebook_path) + # Re-submission is allowed, so the button re-enables; the status line + # switches into run view (design 6.3). + self._repaint_controls() + return handle + + def _on_load_clicked(self, _change: Any = None) -> Optional[RunStatus]: + """The Load button's action: attach to the named run and read status.""" + name = "" + if self._controls is not None: + name = str(self._controls["run_input"].value or "").strip() + name = name or self.run_name + if not name: + raise ValueError("enter a RayJob name to load") + return self.load(name) + + def _on_refresh_clicked(self, _change: Any = None) -> None: + """The Refresh button's action: re-read status and repaint.""" + self.refresh_status() + self._repaint_controls() + + def _ipython_display_(self) -> Any: + """Prefer the interactive button tree; degrade to static HTML. + + IPython calls this for %taugrid; when a widget manager is present the + real Submit button renders, and without one the static _repr_html_ + snapshot is used instead (design: degrade, don't fail). + """ + try: + tree = self.build() + except ImportError as exc: + raise NotImplementedError("ipywidgets not installed") from exc + + from IPython.display import display # type: ignore[import-not-found] + + display(tree) + return None + + +def panel(**kwargs: Any) -> Any: + """Build and return a TauGridPanel. + + This is the one function a platform-authored template cell calls + (import tau.widgets as tg; tg.panel()). + """ + return TauGridPanel(**kwargs) + + +__all__ = ["TauGridPanel", "panel"] diff --git a/sdk/python/python/tau/widgets/render.py b/sdk/python/python/tau/widgets/render.py new file mode 100644 index 00000000..3f895d94 --- /dev/null +++ b/sdk/python/python/tau/widgets/render.py @@ -0,0 +1,158 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Pure HTML/SVG fragments the panel renders (no ipywidgets dependency). + +Keeping these as pure functions means the visual rules are unit-testable offline +(golden text / aria attributes / numeric labels) without a notebook frontend. +""" + +from __future__ import annotations + +import html +from typing import Optional + +from tau.widgets.metrics import MetricSeries, loss_delta +from tau.widgets.status import RunStatus + + +def loss_delta_text(series: MetricSeries) -> str: + """The panel's one-line loss headline: words + numbers above the chart. + + ``loss down 88.4% 0.9230 -> 0.1040``, ``waiting for the first steps`` when + fewer than two points, and a warn-flavored ``loss up`` when it rises. + """ + if not series.has_data: + return "waiting for the first steps" + delta = loss_delta(series) + first, last = series.first, series.last + if first is None or last is None or delta["percent"] is None: + return "waiting for the first steps" + direction = delta["direction"] + if direction == "up": + return f"loss up {delta['percent']:.1f}% (warn) {first.value:.4f} -> {last.value:.4f}" + if direction == "down": + return f"loss down {delta['percent']:.1f}% {first.value:.4f} -> {last.value:.4f}" + return "loss flat" + + +def svg_loss_chart( + series: MetricSeries, + *, + width: int = 300, + height: int = 80, + label: str = "train/loss", +) -> str: + """A minimal, dependency-free inline SVG line chart with an accessible label. + + Returns ``""`` when the series has fewer than two points so the panel can + show the waiting state instead of a meaningless line. + """ + if len(series.samples) < 2: + return "" + xs = [s.step for s in series.samples] + ys = [s.value for s in series.samples] + x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys) + span_x = (x1 - x0) or 1 + span_y = (y1 - y0) or 1.0 + + points: list[str] = [] + for i, s in enumerate(series.samples): + px = 2 + (s.step - x0) / span_x * (width - 4) + py = height - 2 - (s.value - y0) / span_y * (height - 4) + points.append(f"{px:.1f},{py:.1f}") + + polyline = " ".join(points) + return ( + f"" + f"" + ) + + +def gpu_bar(percent: Optional[float], observed: bool, width: int = 20) -> str: + """One per-device GPU utilization bar; renders ``not reported`` when unknown. + + The numeric percentage is printed so color is never the only signal. + """ + if not observed or percent is None: + return "not reported" + fill = max(0, min(1000, int(percent * 10))) # percent in tenths + frac = fill / 10.0 + n = int(round(width * frac)) + n = max(0, min(width, n)) + bar = "\u2588" * n + return f"{html.escape(bar)} {percent:.1f}%" + + +_STATE_LABELS = { + "queued": "Pending (not yet admitted)", + "running": "Running", + "failed": "Failed", + "complete": "Complete", + "not_submitted": "Not submitted", + "unknown": "Unknown", +} + + +def state_badge(state: Optional[str]) -> str: + """A text badge for the run state; text carries the meaning, not color.""" + key = (state or "unknown").lower() + label = _STATE_LABELS.get(key, key.replace("_", " ").title()) + return f"{html.escape(label)}" + + +def status_header_html(status: "RunStatus") -> str: + """The panel's one-line run header built from a normalized RunStatus. + + Includes run identity, the state badge, queue, pod readiness, the Ray + cluster name, and any message. All externally supplied text is escaped. + """ + if not status.existing: + return ( + "
" + f"{state_badge(status.state)} run {html.escape(status.namespace)}/{html.escape(status.name)} not found" + "
" + ) + + parts = [ + f"{html.escape(status.namespace)}/{html.escape(status.name)}", + state_badge(status.state), + ] + if status.queue: + parts.append(f"queue {html.escape(status.queue)}") + if status.total_pods: + parts.append(f"{status.ready_pods}/{status.total_pods} pods ready") + if status.ray_cluster_name: + parts.append(f"ray cluster {html.escape(status.ray_cluster_name)}") + if status.job_id: + parts.append(f"job {html.escape(status.job_id)}") + if status.message: + parts.append(html.escape(status.message)) + + return "
" + " · ".join(parts) + "
" + + +def diagnostics_html(status: "RunStatus") -> str: + """Render diagnostics as a list; empty string when there are none.""" + if not status.diagnostics: + return "" + rows = [] + for item in status.diagnostics: + suggestion = f" {html.escape(item.suggestion)}" if item.suggestion else "" + rows.append( + f"
  • " + f"{html.escape(item.code)} {html.escape(item.message)}{suggestion}
  • " + ) + return "
      " + "".join(rows) + "
    " + + +__all__ = [ + "loss_delta_text", + "svg_loss_chart", + "gpu_bar", + "state_badge", + "status_header_html", + "diagnostics_html", +] \ No newline at end of file diff --git a/sdk/python/python/tau/widgets/session.py b/sdk/python/python/tau/widgets/session.py new file mode 100644 index 00000000..82bc50a8 --- /dev/null +++ b/sdk/python/python/tau/widgets/session.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Resolve the current notebook path from the Jupyter server. + +The kernel resolves the notebook it is running in via the Jupyter Sessions API +(``GET {base}/api/sessions``): the session whose ``kernel.id`` matches this +kernel is authoritative for ``path``. Resolution is a pure function of an +injectable ``opener``/session provider so it can be tested offline. +""" + +from __future__ import annotations + +import json +from typing import Any, Callable, List, Mapping, Optional + + +class NotebookNotResolved(Exception): + """The current notebook could not be resolved from the Jupyter session API.""" + + +def resolve_notebook_path( + sessions: List[Mapping[str, Any]], + kernel_id: Optional[str], +) -> Optional[str]: + """Return the ``path`` for the session matching ``kernel_id``. + + Returns ``None`` (rather than raising) when there is no match, so callers + can show the manual-path state. Prefers the top-level ``path``; falls back to + ``notebook.path`` (legacy). + """ + if not kernel_id: + return None + for session in sessions: + kernel = session.get("kernel") or {} + if str(kernel.get("id")) != str(kernel_id): + continue + path = session.get("path") or (session.get("notebook") or {}).get("path") + if path: + return str(path) + return None + + +class SessionsApiResolver: + """Resolve through a real (or fake) Jupyter ``/api/sessions`` endpoint.""" + + def __init__(self, base_url: str, opener: Optional[Callable[[str], Any]] = None) -> None: + self.base_url = base_url.rstrip("/") + self._opener = opener or _default_sessions_get + + def sessions(self) -> List[Mapping[str, Any]]: + url = f"{self.base_url}/api/sessions" + data = self._opener(url) + if isinstance(data, str): + data = json.loads(data) + return data if isinstance(data, list) else [] + + +def _default_sessions_get(url: str) -> Any: # pragma: no cover - only against a live server + import urllib.request + + with urllib.request.urlopen(url) as resp: + return json.loads(resp.read().decode("utf-8")) + + +__all__ = ["NotebookNotResolved", "resolve_notebook_path", "SessionsApiResolver"] \ No newline at end of file diff --git a/sdk/python/python/tau/widgets/status.py b/sdk/python/python/tau/widgets/status.py new file mode 100644 index 00000000..4a6b8297 --- /dev/null +++ b/sdk/python/python/tau/widgets/status.py @@ -0,0 +1,378 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Run status model + reader for the notebook widget. + +The reader is a pure function of an injectable ClusterClient-shaped object, +so tests can drive it with a recorded fake offline (no cluster, no network). + +Two capabilities live here: + +* read_run_status normalizes one RayJob (plus its pods) into a RunStatus. It + never raises: a missing object becomes state="not_submitted" and a read + failure becomes a diagnostic. +* list_runs discovers RayJobs in a namespace so the panel can offer + "load an existing run" without the caller knowing the name up front. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from tau.widgets.kube import ClusterClient + +RAY_GROUP = "ray.io" +RAY_VERSION = "v1" +RAY_PLURAL = "rayjobs" + +#: Coarse states the panel renders. not_submitted means no object was found. +TERMINAL_STATES = ("complete", "failed") + +#: RayJob status.jobStatus values mapped onto the coarse states. +_COMPLETE_STATUSES = ("SUCCEEDED", "COMPLETED", "SUCCESS") +_FAILED_STATUSES = ("FAILED", "DEAD", "ERROR") +_QUEUED_STATUSES = ("PENDING", "SUSPENDED", "QUEUED") + + +@dataclass +class Diagnostic: + """A user-facing observation about the run, with a suggested next step.""" + + code: str + severity: str # info | warn | error + message: str + suggestion: Optional[str] = None + + +@dataclass +class GPUDevice: + """Per-device GPU observation (mirrors metrics.gpuRuntime.devices).""" + + pod: Optional[str] = None + gpu: Optional[str] = None + utilization_percent: Optional[float] = None + utilization_observed: bool = False + framebuffer_used_mib: Optional[float] = None + framebuffer_used_observed: bool = False + + +@dataclass +class PodInfo: + name: str = "" + phase: Optional[str] = None + node: Optional[str] = None + ready: bool = False + restarts: int = 0 + ray_node_type: Optional[str] = None + + +@dataclass +class RunStatus: + """Normalized status for one RayJob, consumed by the panel.""" + + existing: bool = False + name: str = "" + namespace: str = "" + state: Optional[str] = None # queued | running | failed | complete | not_submitted + display_state: Optional[str] = None + ray_cluster_name: Optional[str] = None + job_id: Optional[str] = None + deployment_status: Optional[str] = None + queue: Optional[str] = None + admitted: Optional[bool] = None + message: Optional[str] = None + reason: Optional[str] = None + pods: List[PodInfo] = field(default_factory=list) + gpu_devices: List[GPUDevice] = field(default_factory=list) + diagnostics: List[Diagnostic] = field(default_factory=list) + + @property + def terminal(self) -> bool: + """True once the run reached an authoritative terminal state.""" + return self.state in TERMINAL_STATES + + @property + def ready_pods(self) -> int: + return sum(1 for pod in self.pods if pod.ready) + + @property + def total_pods(self) -> int: + return len(self.pods) + + +@dataclass(frozen=True) +class RunSummary: + """A lightweight row for the run chooser (list_runs).""" + + name: str + namespace: str + state: Optional[str] = None + queue: Optional[str] = None + created: Optional[str] = None + + +def classify(rayjob: Optional[Dict[str, Any]]) -> str: + """Map a RayJob document to one of the widget's coarse states.""" + if not rayjob: + return "not_submitted" + status = rayjob.get("status") or {} + job = str(status.get("jobStatus") or "").upper() + deployment = str(status.get("jobDeploymentStatus") or "").upper() + reason = str(status.get("reason") or "").upper() + if job in (*_FAILED_STATUSES, "STOPPED", "CANCELLED", "CANCELED") or deployment == "FAILED" or reason in ("SUBMISSIONFAILED", "DEADLINEEXCEEDED", "BACKOFFLIMITEXCEEDED"): + return "failed" + if job in _COMPLETE_STATUSES or deployment in ("COMPLETE", "COMPLETED"): + return "complete" + if job == "RUNNING" or (not job and deployment == "RUNNING"): + return "running" + if (not job and not deployment) or job in _QUEUED_STATUSES or deployment in ("INITIALIZING", "SUSPENDED", "WAITFORCLUSTER", "WAITFORUSER") or (rayjob.get("spec") or {}).get("suspend"): + return "queued" + return "unknown" + + +def read_run_status( + client: ClusterClient, + *, + namespace: str, + name: str, +) -> RunStatus: + """Build a normalized RunStatus from the cluster client. + + The fake client used in offline tests records + get_namespaced_custom_object and list_namespaced_pod returns; a missing + object (None or an ApiException with status 404) yields existing=False. + Never raises: any other read failure is recorded as a diagnostic so the + panel can show a partial status. + """ + info = RunStatus(name=name, namespace=namespace) + + rayjob: Optional[Dict[str, Any]] = None + try: + rayjob = client.custom.get_namespaced_custom_object( + group=RAY_GROUP, version=RAY_VERSION, namespace=namespace, plural=RAY_PLURAL, name=name + ) + info.existing = bool(rayjob) + info.state = classify(rayjob) + except Exception as exc: # missing object or a read failure + if _is_not_found(exc): + info.existing = False + info.state = "not_submitted" + else: + info.existing = False + info.state = "unknown" + info.diagnostics.append( + Diagnostic( + code="status-read-failed", + severity="error", + message=f"could not read RayJob {namespace}/{name}: {exc}", + suggestion="check cluster connectivity and RBAC (get rayjobs.ray.io)", + ) + ) + + if rayjob: + _fill_from_rayjob(info, rayjob) + _read_pods(client, info) + _derive_diagnostics(info) + + info.display_state = (info.state or "unknown").lower() + return info + + +def list_runs(client: ClusterClient, *, namespace: str, label_selector: Optional[str] = None) -> List[RunSummary]: + """List RayJobs in namespace as lightweight summaries. + + Returns an empty list (never raises) when the listing fails, so a run + chooser can degrade to "enter a name manually". + """ + kwargs: Dict[str, Any] = {"group": RAY_GROUP, "version": RAY_VERSION, "namespace": namespace, "plural": RAY_PLURAL} + if label_selector: + kwargs["label_selector"] = label_selector + try: + listing = client.custom.list_namespaced_custom_object(**kwargs) + except Exception: + return [] + + rows: List[RunSummary] = [] + for item in _items(listing): + if not isinstance(item, dict): + continue + metadata = item.get("metadata") or {} + row_name = str(metadata.get("name") or "") + if not row_name: + continue + labels = metadata.get("labels") or {} + rows.append( + RunSummary( + name=row_name, + namespace=namespace, + state=classify(item), + queue=labels.get("kueue.x-k8s.io/queue-name"), + created=metadata.get("creationTimestamp"), + ) + ) + return rows + + +def _fill_from_rayjob(info: RunStatus, rayjob: Dict[str, Any]) -> None: + metadata = rayjob.get("metadata") or {} + status = rayjob.get("status") or {} + labels = metadata.get("labels") or {} + + info.queue = labels.get("kueue.x-k8s.io/queue-name") or info.queue + info.ray_cluster_name = _opt_str(status.get("rayClusterName")) + info.job_id = _opt_str(status.get("jobId")) + info.deployment_status = _opt_str(status.get("jobDeploymentStatus")) + info.reason = _opt_str(status.get("reason")) + info.message = _opt_str(status.get("message")) + + # Kueue admission: a suspended RayJob that has been admitted loses the + # suspend flag; the condition is the authoritative signal when present. + conditions = status.get("conditions") or [] + for condition in conditions: + if not isinstance(condition, dict): + continue + if str(condition.get("type", "")).lower() == "admitted": + info.admitted = str(condition.get("status", "")).lower() == "true" + if condition.get("message"): + info.message = _opt_str(condition.get("message")) + break + + +def _read_pods(client: ClusterClient, info: RunStatus) -> None: + """Read pods for the run, trying each plausible label selector once.""" + seen: Dict[str, PodInfo] = {} + for selector in _pod_selectors(info.name, info.ray_cluster_name): + try: + listing = client.core.list_namespaced_pod(info.namespace, label_selector=selector) + except Exception: + continue + for pod in _items(listing): + pod_info = _pod_info(pod) + if pod_info.name and pod_info.name not in seen: + seen[pod_info.name] = pod_info + info.pods = sorted(seen.values(), key=lambda p: p.name) + + +def _pod_selectors(name: str, ray_cluster_name: Optional[str]) -> List[str]: + selectors = [f"job-name={name}"] + if ray_cluster_name: + selectors.insert(0, f"ray.io/cluster={ray_cluster_name}") + # De-dup while preserving order (the cluster selector is the most precise). + ordered: List[str] = [] + for selector in selectors: + if selector not in ordered: + ordered.append(selector) + return ordered + + +def _pod_info(pod: Any) -> PodInfo: + """Normalize a pod from either a dict (fakes) or a V1Pod (real SDK).""" + metadata = _field(pod, "metadata") or {} + spec = _field(pod, "spec") or {} + status = _field(pod, "status") or {} + labels = _field(metadata, "labels") or {} + + container_statuses = _field(status, "container_statuses", "containerStatuses") or [] + restarts = sum(int(_field(c, "restart_count", "restartCount") or 0) for c in container_statuses) + ready = _pod_ready(status, container_statuses) + return PodInfo( + name=str(_field(metadata, "name") or ""), + phase=_opt_str(_field(status, "phase")), + node=_opt_str(_field(spec, "node_name", "nodeName")), + ready=ready, + restarts=restarts, + ray_node_type=_opt_str(labels.get("ray.io/node-type") if isinstance(labels, dict) else None), + ) + + +def _pod_ready(status: Any, container_statuses: List[Any]) -> bool: + for condition in _field(status, "conditions") or []: + if str(_field(condition, "type") or "").lower() == "ready": + return str(_field(condition, "status") or "").lower() == "true" + if container_statuses: + return all(bool(_field(c, "ready")) for c in container_statuses) + return False + + +def _field(obj: Any, snake: str, camel: Optional[str] = None) -> Any: + """Read a field from a dict (camelCase key) or an SDK object (snake_case).""" + if obj is None: + return None + if isinstance(obj, dict): + if camel is not None and camel in obj: + return obj[camel] + return obj.get(snake) + return getattr(obj, snake, None) + + +def _derive_diagnostics(info: RunStatus) -> None: + if info.state == "failed": + info.diagnostics.append( + Diagnostic( + code="rayjob-failed", + severity="error", + message=info.message or info.reason or "the RayJob reported a failed status", + suggestion="open the pod logs for the failing container and fix the entrypoint", + ) + ) + elif info.state == "queued": + info.diagnostics.append( + Diagnostic( + code="awaiting-admission", + severity="info", + message=info.message or "waiting for Kueue to admit the workload", + suggestion=f"check queue {info.queue or 'default'} quota and pending workloads", + ) + ) + elif info.state == "complete": + info.diagnostics.append( + Diagnostic(code="rayjob-complete", severity="info", message="the RayJob completed successfully") + ) + + restarted = [pod.name for pod in info.pods if pod.restarts > 0] + if restarted: + info.diagnostics.append( + Diagnostic( + code="pod-restarts", + severity="warn", + message=f"{len(restarted)} pod(s) restarted: {', '.join(restarted)}", + suggestion="inspect the previous container logs for the cause", + ) + ) + + +def _is_not_found(exc: Exception) -> bool: + """True for a Kubernetes 404 (missing object), without importing the SDK.""" + status = getattr(exc, "status", None) + if status == 404: + return True + reason = getattr(exc, "reason", None) + if reason and "not found" in str(reason).lower(): + return True + return "not found" in str(exc).lower() + + +def _items(listing: Any) -> list: + if isinstance(listing, dict): + return listing.get("items") or [] + items = getattr(listing, "items", None) + return list(items) if items is not None else [] + + +def _opt_str(value: Any) -> Optional[str]: + return None if value in (None, "") else str(value) + + +__all__ = [ + "RunStatus", + "RunSummary", + "GPUDevice", + "PodInfo", + "Diagnostic", + "read_run_status", + "list_runs", + "classify", + "TERMINAL_STATES", + "ClusterClient", +] diff --git a/sdk/python/python/tau/widgets/watcher.py b/sdk/python/python/tau/widgets/watcher.py new file mode 100644 index 00000000..aaeb03e0 --- /dev/null +++ b/sdk/python/python/tau/widgets/watcher.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Single-flight status watcher for the notebook panel. + +The panel polls a run's status on a fixed delay. Two properties matter and are +enforced here rather than in the panel: + +* Single-flight: the next tick is scheduled only after the previous read + finishes, so a slow API call cannot pile up overlapping requests. +* Generation-tagged: every start/stop bumps a generation counter. A late reply + from a stopped or rebound watcher is discarded instead of repainting the + panel. + +The scheduler and clock are injectable, so the whole loop is testable offline +with a fake scheduler and a fake status reader (no threads, no network). +""" + +from __future__ import annotations + +import threading +from typing import Any, Callable, Optional + +from tau.widgets.status import RunStatus + +#: Default poll interval, matching the design (30s default, 5s minimum). +DEFAULT_INTERVAL = 30.0 +MIN_INTERVAL = 5.0 + + +class Scheduler: + """Minimal scheduler seam: schedule a one-shot callback, cancel it.""" + + def schedule(self, delay: float, callback: Callable[[], Any]) -> Any: # pragma: no cover - seam + raise NotImplementedError + + def cancel(self, handle: Any) -> None: # pragma: no cover - seam + raise NotImplementedError + + +class ThreadScheduler(Scheduler): + """Default scheduler backed by a daemon thread timer.""" + + def schedule(self, delay: float, callback: Callable[[], Any]) -> Any: + timer = threading.Timer(delay, callback) + timer.daemon = True + timer.start() + return timer + + def cancel(self, handle: Any) -> None: + try: + handle.cancel() + except Exception: # pragma: no cover - a fired timer cannot be cancelled + pass + + +class StatusWatcher: + """Poll a run's status on a fixed delay until it reaches a terminal state. + + read_status is a zero-argument callable returning a RunStatus (or None). + on_update is called with each fresh status on the calling thread; the panel + uses it to repaint. Both are injected so tests drive the loop directly. + """ + + def __init__( + self, + read_status: Callable[[], Optional[RunStatus]], + *, + on_update: Optional[Callable[[Optional[RunStatus]], None]] = None, + interval: float = DEFAULT_INTERVAL, + min_interval: float = MIN_INTERVAL, + scheduler: Optional[Scheduler] = None, + ) -> None: + self._read = read_status + self._on_update = on_update + self.interval = max(float(interval), float(min_interval)) + self._scheduler = scheduler or ThreadScheduler() + self._generation = 0 + self._handle: Any = None + self._active = False + + @property + def active(self) -> bool: + return self._active + + @property + def generation(self) -> int: + return self._generation + + def start(self) -> int: + """Begin watching. Returns the new generation for correlation.""" + self._generation += 1 + self._active = True + self._schedule(self._generation) + return self._generation + + def stop(self) -> None: + """Stop watching. Invalidates any in-flight reply via the generation.""" + self._generation += 1 + self._active = False + if self._handle is not None: + self._scheduler.cancel(self._handle) + self._handle = None + + def tick(self) -> Optional[RunStatus]: + """Run one refresh synchronously (used by tests and refresh-now).""" + return self._tick(self._generation) + + def _schedule(self, generation: int) -> None: + if not self._active or generation != self._generation: + return + self._handle = self._scheduler.schedule(self.interval, lambda: self._tick(generation)) + + def _tick(self, generation: int) -> Optional[RunStatus]: + if not self._active or generation != self._generation: + return None + try: + status = self._read() + except Exception: # per-reader failure isolation: keep the loop alive + status = None + # A stop/rebind during the read invalidates this reply. + if generation != self._generation: + return None + if self._on_update is not None: + self._on_update(status) + if status is not None and status.terminal: + self._active = False + return status + self._schedule(generation) + return status + + +__all__ = ["StatusWatcher", "Scheduler", "ThreadScheduler", "DEFAULT_INTERVAL", "MIN_INTERVAL"] diff --git a/sdk/python/python/tests/test_jupyter_contract.py b/sdk/python/python/tests/test_jupyter_contract.py new file mode 100644 index 00000000..ca4985d4 --- /dev/null +++ b/sdk/python/python/tests/test_jupyter_contract.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Offline request/confirmation regressions through the real handlers.""" +import asyncio +import json +from types import SimpleNamespace + +import pytest +from tornado.web import HTTPError + +from tau.jupyter import server +from tests.test_jupyter_submit import FakeClient, FakeCustom, VALID_NB, cluster_doc + + +class Handler(server._SubmitMixin): + current_user = "researcher" + + def __init__(self, body, client): + self.request = SimpleNamespace(body=json.dumps(body).encode()) + self._client = client + self.result = None + + def client(self): + return self._client + + def finish(self, result): + self.result = result + + +def body(): + return {"notebook": VALID_NB.decode(), "namespace": "ray", "name": "reviewed"} + + +@pytest.mark.parametrize("key,value", [("namespace", []), ("name", 42), ("profile", {}), + ("queue", False), ("pip", "requests"), ("pip", [7]), ("env", []), + ("env", {"TOKEN": 2}), ("env_secret", {"TOKEN": []})]) +def test_preview_rejects_malformed_fields(key, value): + handler = Handler({**body(), key: value}, FakeClient(FakeCustom(cluster=cluster_doc()))) + with pytest.raises(HTTPError) as error: + asyncio.run(server.PreviewHandler.post(handler)) + assert error.value.status_code == 400 + + +def test_review_digest_required_and_drift_refused_before_create(monkeypatch): + monkeypatch.setattr(server, "SUBMISSION_ENABLED", True) + api = FakeCustom(cluster=cluster_doc()) + client = FakeClient(api) + review = Handler(body(), client) + asyncio.run(server.PreviewHandler.post(review)) + assert not api.created + for digest in (None, "0" * 64): + attempt = Handler({**body(), "confirm": True, "planDigest": digest}, client) + with pytest.raises(HTTPError) as error: + asyncio.run(server.SubmitHandler.post(attempt)) + assert error.value.status_code in (400, 409) + assert not api.created + digest = review.result["plan"]["planDigest"] + monkeypatch.setenv("TAUGRID_RUNTIME_IMAGE", "example:drifted") + with pytest.raises(HTTPError) as error: + asyncio.run(server.SubmitHandler.post(Handler({**body(), "confirm": True, "planDigest": digest}, client))) + assert error.value.status_code == 409 + assert not api.created + monkeypatch.delenv("TAUGRID_RUNTIME_IMAGE") + attempt = Handler({**body(), "confirm": True, "planDigest": digest}, client) + asyncio.run(server.SubmitHandler.post(attempt)) + assert attempt.result["submitted"] is True + assert len(api.created) == 1 + + +def test_disabled_submit_never_creates(monkeypatch): + monkeypatch.setattr(server, "SUBMISSION_ENABLED", False) + api = FakeCustom(cluster=cluster_doc()) + with pytest.raises(HTTPError) as error: + asyncio.run(server.SubmitHandler.post(Handler({**body(), "confirm": True}, FakeClient(api)))) + assert error.value.status_code == 409 + assert not api.created + + +def test_preview_and_create_run_off_event_loop(monkeypatch): + import threading + monkeypatch.setattr(server, "SUBMISSION_ENABLED", True) + api = FakeCustom(cluster=cluster_doc()) + client = FakeClient(api) + handler = Handler(body(), client) + threads = [] + def get_client(): + threads.append(threading.get_ident()) + return client + handler.client = get_client + asyncio.run(server.PreviewHandler.post(handler)) + handler.request.body = json.dumps({**body(), "confirm": True, "planDigest": handler.result["plan"]["planDigest"]}).encode() + asyncio.run(server.SubmitHandler.post(handler)) + assert len(threads) == 3 + assert all(identity != threading.get_ident() for identity in threads) + assert len(api.created) == 1 + + +@pytest.mark.parametrize("fail", [False, True]) +def test_request_owned_client_is_closed_even_after_failure(fail): + closed = [] + client = SimpleNamespace(close=lambda: closed.append(True)) + handler = SimpleNamespace(client=lambda: client) + def operation(client): + if fail: + raise ValueError("refused") + return 42 + if fail: + with pytest.raises(ValueError): + server._call_with_client(handler, operation) + else: + assert server._call_with_client(handler, operation) == 42 + assert closed == [True] diff --git a/sdk/python/python/tests/test_jupyter_metrics.py b/sdk/python/python/tests/test_jupyter_metrics.py new file mode 100644 index 00000000..803e67d1 --- /dev/null +++ b/sdk/python/python/tests/test_jupyter_metrics.py @@ -0,0 +1,254 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import io +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from tau.jupyter.metrics import Collector, bounded_body, parse_loss +from tau.jupyter.runs import NativeStatus + + +class Body(io.BytesIO): + def set_read_timeout(self, timeout): + assert 0 < timeout <= 5 + + +def test_explicit_finite_samples_and_limits(): + data = b'loss=9\nstep=2 loss=3\nstep=1 loss=4\nstep=2 loss=2\nstep=3 loss=1e999\nstep=9007199254740992 loss=1\nstep=-1 loss=1\n' + samples, reasons = parse_loss(data) + assert samples == [{'step': 1, 'value': 4.0}, {'step': 2, 'value': 2.0}] + assert 'tail-window' in reasons + samples, reasons = parse_loss(b''.join(f'step={step} loss=1\n'.encode() for step in range(600))) + assert len(samples) == 512 + assert samples[0]['step'] == 88 + assert 'point-limit' in reasons + assert 'record-limit' in parse_loss(b'x' * 4097 + b'\n')[1] + assert parse_loss(b'step=1 loss=2')[0] == [] + + +def test_stream_bound_and_close(): + body = Body(b'x' * 100000) + data = bounded_body(body, time.monotonic() + 10) + assert len(data) == 65537 + assert body.closed + + +def test_identity_documents_use_bounded_nonpreloaded_reads(): + from tau._kube_io import read_document, DOCUMENT_BYTES + + calls = [] + body = Body(b'{"metadata":{"uid":"run"}}') + + def reader(**kwargs): + calls.append(kwargs) + return body + + assert read_document(reader, time.monotonic() + 10)["metadata"]["uid"] == "run" + assert len(calls) == 1 + assert calls[0]["_preload_content"] is False + assert 0 < calls[0]["_request_timeout"][0] <= 2 + assert 0 < calls[0]["_request_timeout"][1] <= 5 + assert body.closed + oversized = Body(b" " * (DOCUMENT_BYTES + 1)) + with pytest.raises(ValueError, match="Identity document exceeded"): + read_document(lambda **kwargs: oversized, time.monotonic() + 10) + assert oversized.closed + + +def test_connect_and_read_timeouts_share_remaining_budget(monkeypatch): + from tau.jupyter import metrics + + monkeypatch.setattr(metrics.time, "monotonic", lambda: 100) + connect, read = metrics.timeout(101) + assert connect + read <= 1 + with pytest.raises(TimeoutError): + metrics.timeout(100) + + +def test_cache_singleflight_and_error_staleness(): + clock = [0.0] + collector = Collector(now=lambda: clock[0]) + info = NativeStatus(name='train', namespace='ray', uid='run') + source = {'type': 'stdout', 'runUid': 'run', 'podUid': 'pod', 'pod': 'driver', 'container': 'main', 'ownerKind': 'Job', 'ownerUid': 'job'} + info.metric_sources = [source] + entered, release = threading.Event(), threading.Event() + calls = [] + + def read(client, status, selected, deadline): + calls.append(selected) + entered.set() + assert release.wait(2) + return b'step=1 loss=2\n' + + collector.read = read + with ThreadPoolExecutor() as pool: + task = pool.submit(collector.collect, None, info) + assert entered.wait(2) + assert collector.collect(None, info)['stale'] + release.set() + first = task.result() + assert collector.collect(None, info) == first + assert len(calls) == 1 + clock[0] = 11 + + def fail(*args): + raise OSError('gone') + + collector.read = fail + failed = collector.collect(None, info) + assert failed['samples'] == first['samples'] + assert failed['checkedAt'] == first['checkedAt'] + assert failed['stale'] + info.uid = 'replacement' + info.metric_sources = [] + assert collector.collect(None, info)['samples'] == [] + + +def metric_status(uid='run', pod_uid='pod'): + info = NativeStatus(name='train', namespace='ray', uid=uid) + info.metric_sources = [{'type': 'stdout', 'runUid': uid, 'podUid': pod_uid, + 'pod': 'driver', 'container': 'main', 'ownerKind': 'Job', 'ownerUid': 'job'}] + return info + + +def test_terminal_bypasses_ttl_once_and_source_changes_do_not_replace_evidence(): + clock = [0.0] + collector = Collector(now=lambda: clock[0]) + calls = [] + + def read(*args): + calls.append(1) + return f'step={len(calls)} loss=2\n'.encode() + + collector.read = read + info = metric_status() + collector.collect(None, info) + final = collector.collect(None, info, force=True) + assert len(calls) == 2 + assert collector.collect(None, info, force=True) == final + assert len(calls) == 2 + clock[0] = 11 + collector.collect(None, info, force=True) + assert len(calls) == 3 + changed = collector.collect(None, metric_status(pod_uid='replacement')) + assert changed['stale'] and changed['state'] == 'unavailable' + assert changed['source']['podUid'] == 'pod' + assert changed['samples'] == [{'step': 3, 'value': 2.0}] + assert 'changed' in changed['message'] + assert len(calls) == 3 + + +def test_cache_lru_idle_expiry_and_cached_errors(): + clock = [0.0] + collector = Collector(now=lambda: clock[0]) + collector.read = lambda *args: b'step=1 loss=1\n' + for index in range(128): + collector.collect(None, metric_status(uid=str(index))) + collector.collect(None, metric_status(uid='0')) + collector.collect(None, metric_status(uid='new')) + assert len(collector.entries) == 128 + assert any(entry['target'][-1] == '0' for entry in collector.entries.values()) + assert not any(entry['target'][-1] == '1' for entry in collector.entries.values()) + clock[0] = 600 + collector.collect(None, metric_status(uid='after-idle')) + assert len(collector.entries) == 1 + calls = [] + + def fail(*args): + calls.append(1) + raise OSError('denied') + + collector.read = fail + failed = collector.collect(None, metric_status(uid='error')) + assert failed['state'] == 'error' + assert collector.collect(None, metric_status(uid='error')) == failed + assert len(calls) == 1 + + +def test_capacity_is_bounded_without_waiting(): + collector = Collector() + release = threading.Event() + entered = threading.Barrier(5) + + def read(*args): + entered.wait(timeout=5) + assert release.wait(5) + return b'step=1 loss=1\n' + + collector.read = read + with ThreadPoolExecutor(max_workers=4) as pool: + tasks = [pool.submit(collector.collect, None, metric_status(uid=str(index))) for index in range(4)] + try: + entered.wait(timeout=5) + saturated = collector.collect(None, metric_status(uid='fifth')) + assert saturated['state'] == 'unavailable' and saturated['stale'] + assert 'capacity' in saturated['message'] + assert len(collector.entries) == 4 + finally: + release.set() + assert all(task.result()['state'] == 'ready' for task in tasks) + + +def test_read_deadline_failure_closes_body_and_clipped_records_are_not_samples(): + body = Body(b'step=1 loss=2\n') + with pytest.raises(TimeoutError): + bounded_body(body, time.monotonic() - 1) + assert body.closed + data = b'step=0 loss=9\nstep=1 loss=2\n' + b'x' * 65536 + samples, reasons = parse_loss(data) + assert samples == [{'step': 1, 'value': 2.0}] + assert 'byte-limit' in reasons and 'tail-window' in reasons + + +def test_portal_requires_explicit_uid_mapping_and_preserves_source(monkeypatch): + from tau.jupyter.metrics import portal_source + + info = metric_status() + monkeypatch.setenv('TAUGRID_PORTAL_URL', 'http://portal.example') + monkeypatch.delenv('TAUGRID_METRICS_PORTAL_ENABLED', raising=False) + assert portal_source(info) is None + monkeypatch.setenv('TAUGRID_METRICS_PORTAL_ENABLED', '1') + monkeypatch.setenv('TAUGRID_METRICS_PORTAL_URL', 'http://portal.example') + monkeypatch.setenv('TAUGRID_METRICS_PORTAL_RUNS', '{"ray/RayJob/train/run":{"target":"experiment","run_id":"exact-run"}}') + info.kind = 'RayJob' + selected = portal_source(info) + assert selected['runId'] == 'exact-run' + info.uid = 'replacement' + assert portal_source(info) is None + + +def test_prior_portal_cannot_override_failed_discovery(monkeypatch): + import tau.jupyter.metrics as metrics + + info = metric_status() + info.metric_sources = [] + source = {"type": "portal", "runUid": info.uid, "target": "target", "runId": "run", "url": "http://portal"} + monkeypatch.setattr(metrics, "portal_source", lambda info: source) + calls = [] + monkeypatch.setattr(metrics, "read_portal", lambda *args: (calls.append(1) or ([{"step": 1, "value": 2}], [], {}))) + clock = [0] + collector = Collector(now=lambda: clock[0]) + assert collector.collect(None, info)["state"] == "ready" + clock[0] = 11 + info.metric_discovery_error = True + result = collector.collect(None, info) + assert result["state"] == "unavailable" and result["stale"] + assert len(calls) == 1 + + +def test_metric_cache_is_partitioned_by_cluster(): + from types import SimpleNamespace + + def client(host): + return SimpleNamespace(custom=SimpleNamespace(api_client=SimpleNamespace(configuration=SimpleNamespace(host=host)))) + + collector = Collector() + calls = [] + collector.read = lambda *args: (calls.append(1) or b"step=1 loss=2\n") + collector.collect(client("https://cluster-a"), metric_status()) + collector.collect(client("https://cluster-b"), metric_status()) + assert len(calls) == 2 diff --git a/sdk/python/python/tests/test_jupyter_runs.py b/sdk/python/python/tests/test_jupyter_runs.py new file mode 100644 index 00000000..67aa4bd7 --- /dev/null +++ b/sdk/python/python/tests/test_jupyter_runs.py @@ -0,0 +1,547 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import asyncio +import io +import json +import threading +from types import SimpleNamespace + +import pytest + +from tau.jupyter import runs +from tau.widgets.kube import ClusterClient + + +def resource(name="train", kind="Job", uid="run-uid", **status): + return { + "kind": kind, + "metadata": { + "name": name, "uid": uid, "namespace": "ray", + "labels": {"tau.azure.com/workload": "train", "kueue.x-k8s.io/queue-name": "gpu"}, + "creationTimestamp": "2026-09-20T00:00:00Z", + }, + "spec": {}, "status": status, + } + + +def owner(kind, uid): + return {"kind": kind, "uid": uid, "name": "train", "controller": True} + + +def pod(uid="run-uid", kind="Job", **status): + return { + "metadata": {"name": "worker", "uid": "pod-uid", "ownerReferences": [owner(kind, uid)]}, + "spec": {"containers": [{"name": "main"}], "initContainers": [{"name": "init"}]}, + "status": {"phase": "Pending", **status}, + } + + +class LogBody(io.BytesIO): + def set_read_timeout(self, timeout): + assert 0 < timeout <= 5 + + +class FakeApis: + def __init__(self): + self.job = resource() + self.rayjob = resource(kind="RayJob", jobStatus="PENDING") + self.cluster = resource(kind="RayCluster", uid="cluster-uid") + self.cluster["metadata"]["ownerReferences"] = [owner("RayJob", "run-uid")] + self.jobs = [self.job] + self.rayjobs = [self.rayjob] + self.workloads = [] + self.pods = [pod()] + self.text = "hello" + self.namespaces = [ + {"metadata": {"name": "default"}}, + {"metadata": {"name": "team-a", "labels": {"tau.azure.com/workspace": "team-a"}}}, + {"metadata": {"name": "beta", "labels": {"tau.azure.com/workspace": "beta"}}}, + ] + self.calls = [] + self.denied = set() + + def check(self, key): + if key in self.denied: + raise RuntimeError("forbidden") + + def list_namespaced_job(self, **kwargs): + self.check("jobs") + return {"items": self.jobs, "metadata": {}} + + def read_namespaced_job(self, **kwargs): + return self.job + + def get_namespaced_custom_object(self, **kwargs): + self.check(kwargs["plural"]) + return self.cluster if kwargs["plural"] == "rayclusters" else self.rayjob + + def list_namespaced_custom_object(self, **kwargs): + self.check(kwargs["plural"]) + return {"items": self.rayjobs if kwargs["plural"] == "rayjobs" else self.workloads} + + def list_namespaced_pod(self, **kwargs): + self.check("pods") + return {"items": self.pods} + + def read_namespaced_pod(self, **kwargs): + return self.pods[0] + + def read_namespaced_pod_log(self, **kwargs): + self.calls.append(kwargs) + return LogBody(self.text.encode("utf-8")) + + def list_namespace(self, **kwargs): + self.check("namespaces") + return {"items": self.namespaces, "metadata": {}} + + def client(self): + return ClusterClient(core=self, custom=self, batch=self) + + +def phase(status, key): + return next(item for item in status.phases if item["key"] == key) + + +def test_lists_both_tau_kinds_filters_queue_and_submitters(): + api = FakeApis() + unrelated = resource("unrelated") + unrelated["metadata"]["labels"] = {} + submitter = resource("submitter") + submitter["metadata"]["ownerReferences"] = [owner("RayJob", "parent")] + api.jobs.extend([unrelated, submitter]) + api.rayjob["metadata"]["creationTimestamp"] = "2026-09-21T00:00:00Z" + result = runs.list_runs(api.client(), namespace="ray", queue="gpu") + assert [(row["name"], row["kind"]) for row in result["runs"]] == [("train", "RayJob"), ("train", "Job")] + assert runs.list_runs(api.client(), namespace="ray", queue="other")["runs"] == [] + api.denied.add("jobs") + result = runs.list_runs(api.client(), namespace="ray") + assert len(result["runs"]) == 1 + assert result["warnings"] + + +def test_admission_matches_owner_uid_not_name_and_reports_denial(): + api = FakeApis() + workload = resource("old", conditions=[{"type": "Admitted", "status": "True"}]) + workload["metadata"]["ownerReferences"] = [owner("Job", "old-uid")] + api.workloads = [workload] + assert phase(runs.read_run(api.client(), namespace="ray", name="train", kind="Job"), "admission")["state"] == "unknown" + workload["metadata"]["ownerReferences"] = [owner("Job", "run-uid")] + assert runs.read_run(api.client(), namespace="ray", name="train", kind="Job").admitted is True + api.denied.add("workloads") + status = runs.read_run(api.client(), namespace="ray", name="train", kind="Job") + assert phase(status, "admission")["state"] == "unknown" + assert status.diagnostics + + +def test_retry_pod_is_not_terminal_job_and_waiting_reason_is_visible(): + api = FakeApis() + api.job["status"] = {"failed": 1, "active": 1} + api.pods = [pod(containerStatuses=[{"name": "main", "state": {"waiting": {"reason": "ImagePullBackOff"}}}])] + status = runs.read_run(api.client(), namespace="ray", name="train", kind="Job") + assert status.state == "running" and not status.terminal + assert "ImagePullBackOff" in phase(status, "pods")["detail"] + api.job["status"]["conditions"] = [{"type": "Complete", "status": "True"}] + api.pods = [] + status = runs.read_run(api.client(), namespace="ray", name="train", kind="Job") + assert status.terminal + assert phase(status, "pods")["state"] == "skipped" + + +@pytest.mark.parametrize("over_limit", [False, True]) +def test_incomplete_job_pod_discovery_cannot_select_loss_source(monkeypatch, over_limit): + api = FakeApis() + selected = pod() + selected["metadata"]["uid"] = "pod-uid" + listing = {"items": [selected] * (runs.LIMIT + 1 if over_limit else 1), + "metadata": {} if over_limit else {"continue": "more-pods"}} + monkeypatch.setattr(api, "list_namespaced_pod", lambda **kwargs: listing) + info = runs.read_run(api.client(), namespace="ray", name="train", kind="Job") + assert info.metric_discovery_error + + +@pytest.mark.parametrize("retries,expected", [(None, 7), (0, 0)]) +def test_kubernetes_retry_override_is_explicit(monkeypatch, retries, expected): + from kubernetes import client, config + from tau.widgets.kube import load_client + + configuration = SimpleNamespace(retries=7) + monkeypatch.setattr(config, "load_incluster_config", lambda: None) + monkeypatch.setattr(client.Configuration, "get_default_copy", lambda: configuration) + monkeypatch.setattr(client, "ApiClient", lambda settings: settings) + for api_name in ("CustomObjectsApi", "CoreV1Api", "BatchV1Api"): + monkeypatch.setattr(client, api_name, lambda transport: transport) + connection = load_client(retries=retries) + assert configuration.retries == expected + assert connection.core is connection.custom is connection.batch is configuration + + +def test_cluster_creation_does_not_claim_ready_and_rejects_stale_owners(): + api = FakeApis() + api.rayjob["status"]["rayClusterName"] = "cluster" + api.pods = [pod("cluster-uid", "RayCluster"), pod("old-uid", "RayCluster")] + status = runs.read_run(api.client(), namespace="ray", name="train") + assert phase(status, "cluster")["state"] == "done" + assert status.total_pods == 1 and status.ready_pods == 0 + api.cluster["metadata"]["ownerReferences"] = [owner("RayJob", "old-uid")] + status = runs.read_run(api.client(), namespace="ray", name="train") + assert status.total_pods == 0 + assert phase(status, "cluster")["state"] == "unknown" + + +def test_manager_execution_is_remote_not_locally_running(): + api = FakeApis() + api.job["spec"]["managedBy"] = "kueue.x-k8s.io/multikueue" + status = runs.read_run(api.client(), namespace="ray", name="train", kind="Job") + assert "remote" in phase(status, "execution")["detail"].lower() + assert phase(status, "execution")["state"] == "unknown" + assert status.pods == [] + + +def test_logs_bound_utf8_and_verify_membership_and_options(): + api = FakeApis() + api.text = "€" * 30000 + result = runs.read_logs(api.client(), namespace="ray", name="train", kind="Job", pod="worker", container="main", tail=1000, previous=True, timestamps=True) + assert len(result["text"].encode("utf-8")) <= 65536 + assert "�" not in result["text"] + assert result["possiblyTruncated"] + assert api.calls[0]["follow"] is False + assert api.calls[0]["limit_bytes"] == 65537 + assert api.calls[0]["_preload_content"] is False + assert api.calls[0]["tail_lines"] == 1000 + assert api.calls[0]["previous"] and api.calls[0]["timestamps"] + assert api.calls[0]["_request_timeout"] + for kwargs in ({"pod": "stranger", "container": "main"}, {"pod": "worker", "container": "other"}, {"pod": "worker", "container": "main", "tail": -1}): + with pytest.raises(runs.ReadError): + runs.read_logs(api.client(), namespace="ray", name="train", kind="Job", **kwargs) + assert len(api.calls) == 1 + api.pods[0]["metadata"]["ownerReferences"][0]["uid"] = "old-uid" + with pytest.raises(runs.ReadError): + runs.read_logs(api.client(), namespace="ray", name="train", kind="Job", pod="worker", container="main") + + +def test_portal_configuration_rejects_unsafe_links(monkeypatch): + for url in ("javascript:alert(1)", "//portal.example", "https://user:pass@portal.example"): + monkeypatch.setenv("TAUGRID_PORTAL_URL", url) + assert runs.portal_url() is None + monkeypatch.setenv("TAUGRID_PORTAL_URL", "https://portal.example/base") + assert runs.portal_url() == "https://portal.example/base" + + +def test_real_sdk_model_field_shapes_and_existing_serializer(): + from tau.jupyter.server import _status_dict + + api = FakeApis() + api.job = SimpleNamespace(metadata=SimpleNamespace(name="train", uid="run-uid", labels={}, annotations={}), spec=SimpleNamespace(), status=SimpleNamespace(active=1, conditions=[])) + status = runs.read_run(api.client(), namespace="ray", name="train", kind="Job") + result = _status_dict(status) + assert result["existing"] and result["kind"] == "Job" + assert result["phases"] and result["pods"][0]["containers"] == ["main", "init"] + + +@pytest.mark.parametrize(("status", "expected"), [ + ({"jobStatus": "RUNNING"}, "running"), + ({"jobDeploymentStatus": "Running"}, "running"), + ({"jobDeploymentStatus": "Complete"}, "complete"), + ({"jobStatus": "SUCCEEDED", "jobDeploymentStatus": "Failed"}, "failed"), + ({"jobStatus": "STOPPED"}, "failed"), + ({"jobStatus": "PENDING", "reason": "SubmissionFailed"}, "failed"), + ({"jobStatus": "PENDING"}, "queued"), +]) +def test_ray_execution_uses_authoritative_markers(status, expected): + api = FakeApis() + api.rayjob["status"] = status + result = runs.read_run(api.client(), namespace="ray", name="train") + assert result.state == expected + assert result.terminal == (expected in ("failed", "complete")) + + +def test_batch_failure_takes_precedence_over_completion(): + api = FakeApis() + api.job["status"]["conditions"] = [ + {"type": "Complete", "status": "True"}, + {"type": "Failed", "status": "True"}, + ] + assert runs.read_run(api.client(), namespace="ray", name="train", kind="Job").state == "failed" + + +def test_missing_resources_terminal_teardown_and_partial_reads(monkeypatch): + api = FakeApis() + missing = RuntimeError("not found") + missing.status = 404 + + def not_found(**kwargs): + raise missing + + monkeypatch.setattr(api, "read_namespaced_job", not_found) + assert not runs.read_run(api.client(), namespace="ray", name="train", kind="Job").existing + api.rayjob["status"] = {"jobStatus": "SUCCEEDED", "rayClusterName": "cluster"} + original = api.get_namespaced_custom_object + + def custom_get(**kwargs): + return not_found() if kwargs["plural"] == "rayclusters" else original(**kwargs) + + monkeypatch.setattr(api, "get_namespaced_custom_object", custom_get) + result = runs.read_run(api.client(), namespace="ray", name="train") + assert phase(result, "cluster")["state"] == "skipped" + assert result.state == "complete" + api.denied = {"jobs", "rayjobs"} + listing = runs.list_runs(api.client(), namespace="ray") + assert not listing["runs"] and len(listing["warnings"]) == 2 + + +def test_list_continuation_and_result_metadata(monkeypatch): + api = FakeApis() + monkeypatch.setattr(api, "list_namespaced_job", lambda **kwargs: { + "items": api.jobs, "metadata": {"continue": "next-page"}, + }) + assert runs.list_runs(api.client(), namespace="ray")["truncated"] + api.job["metadata"]["annotations"] = {"tau.azure.com/result-path": "folder/results", "tau.azure.com/result-pvc": "outputs"} + api.job["metadata"]["labels"] = {} + api.denied = {"pods"} + result = runs.read_run(api.client(), namespace="ray", name="train", kind="Job") + assert result.output["path"] == "folder/results" and result.output["pvc"] == "outputs" + assert phase(result, "admission")["state"] == "skipped" + assert phase(result, "pods")["state"] == "unknown" + with pytest.raises(runs.ReadError): + runs.read_logs(api.client(), namespace="ray", name="train", kind="Job", pod="worker", container="main") + assert not api.calls + + +def test_submitter_uid_chain_separates_workers_and_hidden_jobs(): + api = FakeApis() + submitter = resource(name="driver", uid="job-uid") + submitter["metadata"]["ownerReferences"] = [owner("RayJob", "run-uid")] + api.jobs = [submitter] + api.pods = [pod(uid="job-uid", phase="Succeeded")] + api.pods[0]["metadata"]["uid"] = "pod-uid" + info = runs.read_run(api.client(), namespace="ray", name="train", kind="RayJob") + assert info.total_pods == 0 and info.ready_pods == 0 + assert info.pod_roles == {"worker": "submitter"} + assert info.metric_sources[0]["podUid"] == "pod-uid" + assert info.metric_sources[0]["ownerUid"] == "job-uid" + assert all(row["kind"] != "Job" for row in runs.list_runs(api.client(), namespace="ray")["runs"]) + api.jobs[0]["metadata"]["ownerReferences"][0]["uid"] = "old-run" + assert not runs.read_run(api.client(), namespace="ray", name="train", kind="RayJob").metric_sources + + +def test_ambiguous_or_denied_submitters_fail_closed_but_httpmode_still_reads(): + api = FakeApis() + api.rayjob["spec"]["submissionMode"] = "HTTPMode" + assert runs.read_run(api.client(), namespace="ray", name="train", kind="RayJob").state == "queued" + submitter = resource(name="driver", uid="job-uid") + submitter["metadata"]["ownerReferences"] = [owner("RayJob", "run-uid")] + api.jobs = [submitter, submitter] + info = runs.read_run(api.client(), namespace="ray", name="train", kind="RayJob") + assert info.metric_discovery_error and not info.metric_sources + api.denied.add("jobs") + assert runs.read_run(api.client(), namespace="ray", name="train", kind="RayJob").metric_discovery_error + + +def test_metrics_rechecks_entire_uid_chain_before_reading(monkeypatch): + from tau.jupyter.metrics import read_stdout + import time + + api = FakeApis() + api.job = resource(name="driver", uid="job-uid") + api.job["metadata"]["ownerReferences"] = [owner("RayJob", "run-uid")] + selected = pod(uid="job-uid") + selected["metadata"]["uid"] = "pod-uid" + selected["metadata"]["ownerReferences"][0]["name"] = "driver" + monkeypatch.setattr(api, "read_namespaced_pod", lambda **kwargs: selected, raising=False) + info = runs.NativeStatus(name="train", namespace="ray", uid="run-uid") + source = {"type": "stdout", "pod": "worker", "podUid": "pod-uid", "container": "main", "ownerKind": "Job", "ownerUid": "job-uid"} + assert read_stdout(api.client(), info, source, time.monotonic() + 10) == b"hello" + api.job["metadata"]["ownerReferences"][0]["uid"] = "replacement" + with pytest.raises(ValueError, match="controller identity"): + read_stdout(api.client(), info, source, time.monotonic() + 10) + assert len(api.calls) == 1 + selected["metadata"]["uid"] = "replacement" + with pytest.raises(ValueError, match="Pod"): + read_stdout(api.client(), info, source, time.monotonic() + 10) + api.rayjob["metadata"]["uid"] = "replacement" + with pytest.raises(ValueError, match="Workload"): + read_stdout(api.client(), info, source, time.monotonic() + 10) + + +def test_status_metrics_are_opt_in_and_terminal_attempt_is_requested(monkeypatch): + from tau.jupyter import server, metrics + from tornado.web import HTTPError + + api = FakeApis() + api.job["status"] = {"conditions": [{"type": "Complete", "status": "True"}]} + calls = [] + + def collect(client, status, force=False): + calls.append(force) + return metrics.empty("No evidence") + + monkeypatch.setattr(metrics.collector, "collect", collect) + handler = FakeHandler(api, namespace="ray", name="train", kind="Job") + asyncio.run(server.StatusHandler.get(handler)) + assert "metrics" not in handler.response and calls == [] + handler.arguments["includeMetrics"] = "true" + asyncio.run(server.StatusHandler.get(handler)) + assert handler.response["terminal"] and handler.response["metrics"]["state"] == "unavailable" + assert calls == [True] + handler.arguments["includeMetrics"] = "yes" + with pytest.raises(HTTPError) as caught: + asyncio.run(server.StatusHandler.get(handler)) + assert caught.value.status_code == 400 + + +class FakeHandler: + def __init__(self, api, **arguments): + self.api = api + self.arguments = arguments + self.current_user = object() + self.request = SimpleNamespace(method="POST", body=b"{}") + self.headers = {} + self.response = None + + def client(self): + return self.api.client() + + def get_argument(self, name, default): + return self.arguments.get(name, default) + + def finish(self, value): + self.response = value + + def set_header(self, name, value): + self.headers[name] = value + + def target(self): + from tau.jupyter.server import _ClientMixin + return _ClientMixin.target(self) + + async def read(self, reader, **kwargs): + from tau.jupyter.server import _ClientMixin + return await _ClientMixin.read(self, reader, **kwargs) + + def json_body(self): + return json.loads(self.request.body) + + +def test_offline_handlers_roundtrip_and_threaded_reads(monkeypatch): + from tau.jupyter import server + api = FakeApis() + handler = FakeHandler(api, namespace="ray", name="train", kind="Job", pod="worker", container="main") + main_thread = threading.get_ident() + threads = [] + original = api.read_namespaced_job + + def read_job(**kwargs): + threads.append(threading.get_ident()) + return original(**kwargs) + + monkeypatch.setattr(api, "read_namespaced_job", read_job) + asyncio.run(server.StatusHandler.get(handler)) + assert handler.response["kind"] == "Job" + assert threads and all(thread != main_thread for thread in threads) + asyncio.run(server.RunsHandler.get(handler)) + assert {row["kind"] for row in handler.response["runs"]} == {"Job", "RayJob"} + asyncio.run(server.LogsHandler.get(handler)) + assert handler.response["text"] == "hello" + assert handler.headers["Cache-Control"] == "no-store" + assert api.calls[0]["tail_lines"] == 200 + monkeypatch.setenv("TAUGRID_PORTAL_URL", "https://portal.example/") + server.CapabilitiesHandler.get(handler) + assert handler.response["portalUrl"] == "https://portal.example/" + handler.arguments.pop("kind") + asyncio.run(server.StatusHandler.get(handler)) + assert handler.response["kind"] == "RayJob" + + +@pytest.mark.parametrize("arguments", [{"tail": "bad"}, {"tail": "1001"}, {"previous": "yes"}, {"timestamps": "1"}, {"kind": "Pod"}, {"namespace": "../other"}, {"pod": "unrelated"}]) +def test_log_handler_rejects_bad_inputs(arguments): + from tornado.web import HTTPError + from tau.jupyter import server + api = FakeApis() + options = {"namespace": "ray", "name": "train", "kind": "Job", "pod": "worker", "container": "main", **arguments} + with pytest.raises(HTTPError) as failure: + asyncio.run(server.LogsHandler.get(FakeHandler(api, **options))) + assert failure.value.status_code in (400, 403) + assert not api.calls + + +def test_handlers_require_authentication_and_preserve_submit_gate(monkeypatch): + from tornado.web import HTTPError + from tau.jupyter import server + handler = FakeHandler(FakeApis()) + handler.current_user = None + handlers = server._handlers("/user/example/taugrid/api") + assert {path.rsplit("/", 1)[-1] for path, _ in handlers} == {"capabilities", "status", "runs", "namespaces", "files", "logs", "preview", "submit"} + for path, handler_type in handlers: + method = handler_type.post if path.endswith(("preview", "submit")) else handler_type.get + with pytest.raises(HTTPError) as failure: + method(handler) + assert failure.value.status_code == 403 + handler.current_user = object() + monkeypatch.setattr(server, "SUBMISSION_ENABLED", False) + with pytest.raises(HTTPError) as failure: + asyncio.run(server.SubmitHandler.post(handler)) + assert failure.value.status_code == 409 + monkeypatch.setattr(server, "SUBMISSION_ENABLED", True) + with pytest.raises(HTTPError) as failure: + asyncio.run(server.SubmitHandler.post(handler)) + assert failure.value.status_code == 400 + +def test_namespace_discovery_flags_tau_ready_and_orders_them_first(): + api = FakeApis() + + result = runs.list_namespaces(api.client()) + + # Tau-ready destinations come first so the picker never makes a researcher + # guess a namespace that cannot host the run. + assert [row["name"] for row in result["namespaces"]] == ["beta", "team-a", "default"] + assert [row["tauEnabled"] for row in result["namespaces"]] == [True, True, False] + assert result["warnings"] == [] + + +def test_namespace_discovery_reports_denial_without_raising(): + api = FakeApis() + api.denied.add("namespaces") + + result = runs.list_namespaces(api.client()) + + assert result["namespaces"] == [] + assert result["warnings"] and "namespace discovery failed" in result["warnings"][0] + + +def test_namespace_continuation_warns_even_for_short_pages(): + api = FakeApis() + api.list_namespace = lambda **kwargs: {"items": api.namespaces[:1], "metadata": {"continue": "next"}} + assert runs.list_namespaces(api.client())["warnings"] + + +def test_ray_status_uses_explicit_states_not_reason_substrings(): + from tau.widgets.status import classify + + for status, expected in (({}, "queued"), ({"jobStatus": "RUNNING", "reason": "Stopping"}, "running"), + ({"jobStatus": "STOPPED"}, "failed"), + ({"jobStatus": "SUCCEEDED"}, "complete"), + ({"jobStatus": "FUTURE_STATE"}, "unknown")): + obj = {"status": status} + assert runs.ray_state(obj) == expected + assert classify(obj) == expected + + +@pytest.mark.parametrize("changed", ["pod", "run", "container"]) +def test_logs_discard_evidence_when_identity_changes_during_read(changed): + api = FakeApis() + original = api.read_namespaced_pod_log + + def replace(**kwargs): + response = original(**kwargs) + if changed == "pod": + api.pods[0]["metadata"]["uid"] = "replacement" + elif changed == "run": + api.job["metadata"]["uid"] = "replacement" + else: + api.pods[0]["spec"]["containers"] = [{"name": "other"}] + return response + + api.read_namespaced_pod_log = replace + with pytest.raises(runs.ReadError): + runs.read_logs(api.client(), namespace="ray", name="train", kind="Job", pod="worker", container="main") diff --git a/sdk/python/python/tests/test_jupyter_submit.py b/sdk/python/python/tests/test_jupyter_submit.py new file mode 100644 index 00000000..da9b4ec6 --- /dev/null +++ b/sdk/python/python/tests/test_jupyter_submit.py @@ -0,0 +1,320 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Offline tests for the server-side submit path (tau.jupyter.submit). + +No cluster, no network: the Kubernetes client and backend are small fakes, and +the whole chain (package -> resolve -> render -> apply) runs in-process. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +import pytest + +from tau._payload import decode +from tau.jupyter.submit import ( + SubmitError, + _safe_name, + build_plan, + submit_notebook, + submit_plan, +) + +VALID_NB = json.dumps( + { + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": {"language": "python", "name": "python3"}, + "language_info": {"name": "python"}, + }, + "cells": [ + {"cell_type": "code", "id": "c0", "metadata": {}, "outputs": [{"x": 1}], "execution_count": 3, "source": ["print(1)"]}, + ], + } +).encode() + + +class NotFound(Exception): + def __init__(self) -> None: + super().__init__("rayjobs.ray.io not found") + self.status = 404 + + +class FakeCustom: + def __init__(self, cluster: Optional[Dict[str, Any]] = None, existing: bool = False) -> None: + self.cluster = cluster + self.existing = existing + self.created: List[Dict[str, Any]] = [] + + def list_cluster_custom_object(self, **kwargs: Any) -> Dict[str, Any]: + if self.cluster is None: + return {"items": []} + return {"items": [self.cluster]} + + def get_namespaced_custom_object(self, **kwargs: Any) -> Dict[str, Any]: + if self.existing: + return {"metadata": {"name": kwargs.get("name")}} + raise NotFound() + + def create_namespaced_custom_object(self, **kwargs: Any) -> Dict[str, Any]: + if self.existing: + error = RuntimeError("already exists") + error.status = 409 + raise error + self.created.append(kwargs) + return {"metadata": kwargs["body"]["metadata"]} + + +class FakeCore: + pass + + +class FakeClient: + def __init__(self, custom: FakeCustom) -> None: + self.custom = custom + self.core = FakeCore() + + +def cluster_doc() -> Dict[str, Any]: + return { + "spec": { + "workspaceDefaults": {"defaultQueue": "research-gpu"}, + "workloadProfiles": [ + {"name": "training-8gpu", "status": {"state": "ready"}, "compute": {"workerReplicas": 2, "gpusPerWorker": 8}}, + {"name": "cpu", "status": {"state": "ready"}, "compute": {"workerReplicas": 1, "gpusPerWorker": 0}}, + ], + } + } + + +def test_build_plan_embeds_a_self_contained_payload(): + client = FakeClient(FakeCustom(cluster=cluster_doc())) + + plan = build_plan(client=client, notebook_bytes=VALID_NB, namespace="ray", name="demo") + + assert plan.name == "demo" + assert plan.namespace == "ray" + assert plan.queue == "research-gpu" + assert plan.profile == "training-8gpu" + assert plan.manifest["spec"]["submissionMode"] == "K8sJobMode" + assert plan.manifest["spec"]["ttlSecondsAfterFinished"] == 600 + submitter = plan.manifest["spec"]["submitterPodTemplate"]["spec"] + assert submitter["restartPolicy"] == "Never" + assert submitter["containers"][0]["name"] == "submitter" + # The submitter runs the entrypoint, so it must carry the payload and mounts. + assert [c["name"] for c in submitter["initContainers"]] == ["tau-payload"] + assert {v["name"] for v in submitter["volumes"]} >= {"script", "data"} + mounts = {m["name"]: m["mountPath"] for m in submitter["containers"][0]["volumeMounts"]} + assert mounts["script"] == "/script" + assert mounts["data"] == "/data" + assert plan.summary()["submissionMode"] == "K8sJobMode" + assert plan.summary()["retentionSeconds"] == 600 + assert plan.digest + + spec = plan.manifest["spec"] + head = spec["rayClusterSpec"]["headGroupSpec"]["template"]["spec"] + init = head["initContainers"][0] + assert init["name"] == "tau-payload" + env = {e["name"]: e["value"] for e in init["env"]} + files = decode(env["TAU_PAYLOAD_B64"], env["TAU_PAYLOAD_DIGEST"]) + # the prepared notebook, the runner, and the context manifest all ship + assert set(files) == {"analysis.ipynb", "_tau_runner.py", "_tau_notebook_context.json"} + assert plan.manifest["metadata"]["annotations"]["tau.azure.com/payload-digest"] == plan.digest + assert plan.manifest["metadata"]["labels"]["kueue.x-k8s.io/queue-name"] == "research-gpu" + # CLI parity: the managed-by label is what makes a run discoverable by list. + assert plan.manifest["metadata"]["labels"]["tau.azure.com/managed-by"] == "tau" + # Plugin submissions keep pods for 600s so a finished run can still be read. + assert spec["ttlSecondsAfterFinished"] == 600 + # Plugin submissions use K8sJobMode so the driver output is a readable pod log. + assert spec["submissionMode"] == "K8sJobMode" + # outputs are stripped before embedding + assert b"\"outputs\":[]" in files["analysis.ipynb"] + + +def test_build_plan_honours_explicit_profile_and_queue(): + client = FakeClient(FakeCustom(cluster=cluster_doc())) + + plan = build_plan( + client=client, notebook_bytes=VALID_NB, namespace="ray", profile="cpu", queue="team-q" + ) + + assert plan.profile == "cpu" + assert plan.queue == "team-q" + worker = plan.manifest["spec"]["rayClusterSpec"]["workerGroupSpecs"][0] + assert worker["rayStartParams"]["num-gpus"] == "0" + assert plan.summary()["gpusPerWorker"] == [0] + + +def test_build_plan_rejects_unknown_profile(): + client = FakeClient(FakeCustom(cluster=cluster_doc())) + with pytest.raises(SubmitError) as err: + build_plan(client=client, notebook_bytes=VALID_NB, namespace="ray", profile="nope") + assert "not available" in str(err.value) + assert err.value.status == 400 + + +def test_build_plan_rejects_malformed_notebook(): + client = FakeClient(FakeCustom(cluster=cluster_doc())) + with pytest.raises(SubmitError) as err: + build_plan(client=client, notebook_bytes=b"not json", namespace="ray") + assert "cannot submit" in str(err.value) + assert err.value.status == 400 + + +def test_build_plan_reports_oversize_as_413(): + client = FakeClient(FakeCustom(cluster=cluster_doc())) + with pytest.raises(SubmitError) as err: + build_plan(client=client, notebook_bytes=VALID_NB, namespace="ray", input_cap=10) + assert err.value.status == 413 + + +def test_build_plan_requires_a_taucluster(): + client = FakeClient(FakeCustom(cluster=None)) + with pytest.raises(SubmitError) as err: + build_plan(client=client, notebook_bytes=VALID_NB, namespace="ray") + assert err.value.status == 409 + assert "no TauCluster" in str(err.value) + + +def test_build_plan_rejects_env_and_secret_collision(): + client = FakeClient(FakeCustom(cluster=cluster_doc())) + with pytest.raises(SubmitError): + build_plan( + client=client, + notebook_bytes=VALID_NB, + namespace="ray", + env={"TOKEN": "literal"}, + env_secret={"TOKEN": "hf:token"}, + ) + + +def test_submit_plan_applies_once_and_returns_handle(): + custom = FakeCustom(cluster=cluster_doc()) + client = FakeClient(custom) + plan = build_plan(client=client, notebook_bytes=VALID_NB, namespace="ray", name="demo") + + result = submit_plan(client=client, plan=plan) + + assert result.name == "demo" and result.namespace == "ray" and result.kind == "RayJob" + assert result.digest == plan.digest + assert len(custom.created) == 1 + assert custom.created[0]["body"]["kind"] == "RayJob" + assert custom.created[0]["plural"] == "rayjobs" + + +def test_submit_plan_refuses_to_replace_an_existing_run(): + custom = FakeCustom(cluster=cluster_doc(), existing=True) + client = FakeClient(custom) + plan = build_plan(client=client, notebook_bytes=VALID_NB, namespace="ray", name="demo") + + with pytest.raises(SubmitError) as err: + submit_plan(client=client, plan=plan) + + assert err.value.status == 409 + assert custom.created == [] + + +def test_submit_notebook_end_to_end(): + custom = FakeCustom(cluster=cluster_doc()) + client = FakeClient(custom) + + result = submit_notebook(client=client, notebook_bytes=VALID_NB, namespace="ray", name="demo") + + assert result.name == "demo" + assert len(custom.created) == 1 + + +def test_safe_name_sanitizes_and_bounds(): + assert _safe_name("my run/../x") == "my-run-x" + assert len(_safe_name("a" * 200)) <= 63 + assert _safe_name("///") == "notebook-run" + + +def test_review_is_in_memory_and_plan_digest_covers_resolved_runtime(monkeypatch): + import tempfile + + def forbid_temp(*args, **kwargs): + raise AssertionError("preview must not persist notebook bytes") + + monkeypatch.setattr(tempfile, "mkdtemp", forbid_temp) + client = FakeClient(FakeCustom(cluster=cluster_doc())) + first = build_plan(client=client, notebook_bytes=VALID_NB) + same = build_plan(client=client, notebook_bytes=VALID_NB) + assert first.summary()["planDigest"] == same.summary()["planDigest"] + monkeypatch.setenv("TAUGRID_RUNTIME_IMAGE", "example:changed") + changed = build_plan(client=client, notebook_bytes=VALID_NB) + assert changed.summary()["planDigest"] != first.summary()["planDigest"] + + +def test_cluster_discovery_rejects_ambiguous_or_incomplete_lists(): + class Many(FakeCustom): + def list_cluster_custom_object(self, **kwargs): + assert kwargs["limit"] == 2 + assert kwargs["_preload_content"] is False + assert kwargs["_request_timeout"] + return self.listing + + custom = Many() + for listing in ({"items": [cluster_doc(), cluster_doc()]}, + {"items": [cluster_doc()], "metadata": {"continue": "more"}}): + custom.listing = listing + with pytest.raises(SubmitError, match="ambiguous"): + build_plan(client=FakeClient(custom), notebook_bytes=VALID_NB) + + +def test_profile_preserves_flat_scheduling_and_rejects_invalid_counts(): + from tau._profile import workload_profiles + + document = {"spec": {"workloadProfiles": [{"name": "cpu", "workerCount": 2, + "gpusPerWorker": 0, "cpusPerWorker": 3, "memoryPerWorker": "6Gi", + "nodeSelector": {"pool": "cpu"}}]}} + profile = workload_profiles(document)[0] + assert profile.node_selector == {"pool": "cpu"} + assert profile.memory_per_worker == "6Gi" + for count in (0, -1, True, 1.5): + document["spec"]["workloadProfiles"][0]["workerCount"] = count + with pytest.raises(ValueError): + workload_profiles(document) + + +def test_malformed_notebook_structure_is_user_error(): + for document in ([1], {"nbformat": 4, "cells": [], "metadata": []}, + {"nbformat": 4, "cells": [{"cell_type": "code", "source": [7]}], + "metadata": {"kernelspec": {"language": "python"}}}): + with pytest.raises(SubmitError) as error: + build_plan(client=FakeClient(FakeCustom()), notebook_bytes=json.dumps(document).encode()) + assert error.value.status == 400 + + +def test_launcher_filter_never_discards_unrelated_panel_calls(): + from tau._notebook_pkg import prepare_notebook + document = json.loads(VALID_NB) + document["cells"][0]["source"] = ["import tau.widgets as tw\n", "model.panel()"] + prepared, dropped = prepare_notebook(json.dumps(document).encode()) + assert not dropped + assert "model.panel()" in prepared.decode() + + +@pytest.mark.parametrize("metadata", [[], None, "python"]) +def test_invalid_metadata_is_rejected(metadata): + document = json.loads(VALID_NB) + document["metadata"] = metadata + with pytest.raises(SubmitError) as error: + build_plan(client=FakeClient(FakeCustom()), notebook_bytes=json.dumps(document).encode()) + assert error.value.status == 400 + + +@pytest.mark.parametrize("source,expected", [ + ("import tau.widgets as tw\ntw.panel(namespace='ray')", True), + ("from tau.widgets import panel\npanel()", True), + ("import tau.widgets as tw; important_work()", False), + ("import tau.widgets as tw\ntw.panel(namespace=side_effect())", False), + ("%load_ext tau.widgets.ipython\n%taugrid", True), +]) +def test_launcher_recognition_requires_only_known_statements(source, expected): + from tau._notebook_pkg import classify_launcher_cell + assert classify_launcher_cell({"source": source}) is expected diff --git a/sdk/python/python/tests/test_notebook_files.py b/sdk/python/python/tests/test_notebook_files.py new file mode 100644 index 00000000..2ee36045 --- /dev/null +++ b/sdk/python/python/tests/test_notebook_files.py @@ -0,0 +1,132 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for choosing which files ship with a submitted notebook.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tau._notebook_pkg import package +from tau._payload import decode +from tau.jupyter import notebook_files +from tau.jupyter.notebook_files import ( + MAX_FILE_BYTES, + FileSelectionError, + list_candidates, + notebook_directory, + read_selected, +) + +NOTEBOOK = "analysis.ipynb" + + +def notebook_bytes() -> bytes: + return json.dumps({ + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {"kernelspec": {"language": "python", "name": "python3"}, "language_info": {"name": "python"}}, + "cells": [{"cell_type": "code", "id": "c", "metadata": {}, "outputs": [], "execution_count": None, "source": ["print(1)"]}], + }).encode() + + +def workspace(tmp_path: Path) -> Path: + (tmp_path / NOTEBOOK).write_bytes(notebook_bytes()) + (tmp_path / "helpers.py").write_bytes(b"VALUE = 41\n") + (tmp_path / "config.json").write_bytes(b"{}\n") + (tmp_path / "notes.md").write_bytes(b"# notes\n") + (tmp_path / "image.png").write_bytes(b"\x89PNG") + (tmp_path / ".hidden").write_text("x", encoding="utf-8") + (tmp_path / "__pycache__").mkdir() + (tmp_path / "__pycache__" / "junk.pyc").write_bytes(b"x") + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "deep.py").write_text("x = 1\n", encoding="utf-8") + return tmp_path + + +def test_candidates_offer_flat_source_files_and_skip_noise(tmp_path): + root = workspace(tmp_path) + + result = list_candidates(root, NOTEBOOK) + + names = [row["name"] for row in result["files"]] + assert names == ["config.json", "helpers.py", "notes.md"] + # The notebook itself, hidden files, caches, binaries and nested trees are not offered. + for skipped in (NOTEBOOK, ".hidden", "image.png", "nested/deep.py"): + assert skipped not in names + assert result["budgetBytes"] > 0 + + +def test_candidates_warn_about_a_file_too_large_to_embed(tmp_path): + root = workspace(tmp_path) + (root / "big.py").write_bytes(b"x" * (MAX_FILE_BYTES + 1)) + + result = list_candidates(root, NOTEBOOK) + + assert "big.py" not in [row["name"] for row in result["files"]] + assert any("big.py" in warning for warning in result["warnings"]) + + +def test_notebook_directory_rejects_escape_from_the_server_root(tmp_path): + root = workspace(tmp_path) + outside = tmp_path.parent / "elsewhere.ipynb" + + with pytest.raises(FileSelectionError): + notebook_directory(root, "../" + outside.name) + + +def test_read_selected_refuses_paths_outside_the_flat_directory(tmp_path): + root = workspace(tmp_path) + directory = notebook_directory(root, NOTEBOOK) + + for name in ("../helpers.py", "nested/deep.py", "/etc/passwd", ".."): + with pytest.raises(FileSelectionError): + read_selected(directory, [name]) + + +def test_read_selected_refuses_missing_and_oversize_files(tmp_path): + root = workspace(tmp_path) + directory = notebook_directory(root, NOTEBOOK) + (root / "big.py").write_bytes(b"x" * (MAX_FILE_BYTES + 1)) + + with pytest.raises(FileSelectionError): + read_selected(directory, ["nope.py"]) + + with pytest.raises(FileSelectionError) as failure: + read_selected(directory, ["big.py"]) + assert failure.value.status == 413 + + +def test_read_selected_enforces_the_payload_ceiling(tmp_path, monkeypatch): + root = workspace(tmp_path) + directory = notebook_directory(root, NOTEBOOK) + monkeypatch.setattr(notebook_files, "MAX_DECODED_BYTES", 10) + + with pytest.raises(FileSelectionError) as failure: + read_selected(directory, ["helpers.py", "config.json"]) + assert failure.value.status == 413 + assert "payload ceiling" in str(failure.value) + + +def test_chosen_files_ride_the_payload_and_the_plan_summary(tmp_path): + root = workspace(tmp_path) + selected = read_selected(notebook_directory(root, NOTEBOOK), ["helpers.py", "config.json"]) + + staged = package(notebook_bytes(), staging_dir=tmp_path / "stage", extra_files=selected) + + assert staged.included_files == ["config.json", "helpers.py"] + files = decode(staged.encoding.encoded, staged.encoding.digest) + assert files["helpers.py"] == b"VALUE = 41\n" + assert files["config.json"] == b"{}\n" + # The chosen files are staged for inspection too. + assert (tmp_path / "stage" / "helpers.py").exists() + + +def test_package_refuses_a_collision_with_a_generated_file(tmp_path): + from tau._notebook_pkg import NotebookInvalid + + with pytest.raises(NotebookInvalid): + package(notebook_bytes(), staging_dir=tmp_path, extra_files={"_tau_runner.py": b"x"}) diff --git a/sdk/python/python/tests/test_notebook_loss_demo.py b/sdk/python/python/tests/test_notebook_loss_demo.py new file mode 100644 index 00000000..192d0f54 --- /dev/null +++ b/sdk/python/python/tests/test_notebook_loss_demo.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import importlib.util +import json +import math +from pathlib import Path + +import pytest + + +@pytest.fixture +def demo(): + path = Path(__file__).resolve().parents[4] / "tools" / "run-cpu-ray-demo.py" + spec = importlib.util.spec_from_file_location("notebook_loss_demo", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def prepare(demo, monkeypatch, samples, *, gpus=0, state="complete", source_uid="run"): + calls = [] + plan = {"name": "reviewed", "namespace": "ray", "profile": "cpu", + "gpusPerWorker": [gpus], "submissionMode": "K8sJobMode", "retentionSeconds": 600, "planDigest": "a" * 64} + + def call(server, token, path, **kwargs): + calls.append((path, kwargs)) + if path == "capabilities": + return {"submissionEnabled": True} + if path == "preview": + return {"submittable": True, "submissionEnabled": True, "plan": plan} + if path == "submit": + return {"submitted": True, "name": "reviewed", "namespace": "ray", "kind": "RayJob"} + if path == "status": + return {"uid": "run", "terminal": True, "state": state, "metrics": { + "source": {"type": "stdout", "runUid": source_uid, "pod": "driver", "container": "submitter"}, + "checkedAt": "2026-09-22T00:00:00Z", "samples": samples}} + if path == "logs": + return {"text": "step=1 loss=2\n", "possiblyTruncated": True} + raise AssertionError(path) + + monkeypatch.setattr(demo, "call", call) + return calls + + +@pytest.mark.parametrize("samples", [[], [{"step": 1, "value": math.nan}], + [{"step": -1, "value": 2}], [{"step": 1, "value": True}]]) +def test_success_without_real_finite_loss_fails(demo, monkeypatch, samples): + calls = prepare(demo, monkeypatch, samples) + assert demo.main(["--token", "test", "--profile", "cpu"]) == 1 + assert [path for path, _ in calls].count("submit") == 1 + + +def test_loss_success_submits_once_and_polls_opt_in(demo, monkeypatch): + calls = prepare(demo, monkeypatch, [{"step": 1, "value": 2}]) + assert demo.main(["--token", "test", "--profile", "cpu", "--queue", "cpu-q"]) == 0 + assert [path for path, _ in calls] == ["capabilities", "preview", "submit", "status", "logs"] + reviewed = calls[1][1]["body"] + submitted = calls[2][1]["body"] + assert submitted["planDigest"] == "a" * 64 + assert submitted["notebook"] == reviewed["notebook"] + assert submitted["profile"] == "cpu" and submitted["queue"] == "cpu-q" + assert submitted["name"] == "reviewed" and submitted["confirm"] is True + assert calls[3][1]["params"]["includeMetrics"] == "true" + + +@pytest.mark.parametrize("options", [{"gpus": 1}, {"state": "failed"}, {"source_uid": "other"}]) +def test_gpu_failed_or_replaced_run_cannot_pass(demo, monkeypatch, options): + calls = prepare(demo, monkeypatch, [{"step": 1, "value": 2}], **options) + assert demo.main(["--token", "test", "--profile", "cpu"]) == 1 + if options.get("gpus"): + assert "submit" not in [path for path, _ in calls] + + +def test_demo_notebook_is_deterministic_and_cpu_only(demo): + notebook = json.loads(demo.NOTEBOOK.read_text(encoding="utf-8")) + source = "".join(notebook["cells"][1]["source"]) + assert "step=" in source and "loss=" in source and "flush=True" in source + assert "import time" in source + assert "random" not in source and "torch" not in source diff --git a/sdk/python/python/tests/test_notebook_packaging.py b/sdk/python/python/tests/test_notebook_packaging.py new file mode 100644 index 00000000..fb78ddfe --- /dev/null +++ b/sdk/python/python/tests/test_notebook_packaging.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Check wheel packaging refuses an absent or incomplete frontend.""" +import json +import runpy +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +@pytest.mark.parametrize("with_manifest", [False, True]) +def test_packaging_refuses_missing_prebuilt_assets(tmp_path, monkeypatch, with_manifest): + source = Path(__file__).parents[1] / "setup.py" + copied = tmp_path / "setup.py" + copied.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + if with_manifest: + folder = tmp_path / "tau" / "labextension" + folder.mkdir(parents=True) + (folder / "package.json").write_text(json.dumps({"jupyterlab": {"_build": {"load": "static/remoteEntry.missing.js"}}}), encoding="utf-8") + monkeypatch.setitem(sys.modules, "setuptools", SimpleNamespace(setup=lambda **kwargs: None)) + with pytest.raises(RuntimeError, match="prebuilt"): + runpy.run_path(str(copied)) diff --git a/sdk/python/python/tests/test_widgets_button.py b/sdk/python/python/tests/test_widgets_button.py new file mode 100644 index 00000000..3da2ec0d --- /dev/null +++ b/sdk/python/python/tests/test_widgets_button.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Offline tests for the panel's interactive Submit button (S005/S008 surface). + +The widget tree is constructible without a frontend: ipywidgets objects are +plain Python until displayed. A click is simulated by invoking the same handler +the button's ``on_click`` registers, which is the exact path a user click drives. +""" + +import tempfile +from pathlib import Path + +import pytest + +from tau._backend import KubernetesBackend +from tau._render import Profile, TAU_QUEUE_LABEL +from tau.widgets.kube import ClusterClient +from tau.widgets.panel import TauGridPanel +from tests.test_widgets_submit import FakeCustomApi, NB_BYTES, _cluster_doc + + +def _real_notebook(tmp_path: Path) -> str: + """Write a real notebook file so submit() can read its bytes.""" + src = tmp_path / "analysis.ipynb" + src.write_bytes(NB_BYTES) + return str(src) + + +def _panel_with_button(cluster=None): + fake = FakeCustomApi(cluster=cluster) + panel = TauGridPanel(namespace="ray", run_name="demo", + client=ClusterClient(custom=fake, core=None)) + tree = panel.build() + return panel, tree, fake + + +def test_build_creates_submit_button_with_expected_labels(): + panel, tree, _ = _panel_with_button() + assert panel._controls is not None + submit = panel._controls["submit"] + assert submit.description == "Submit notebook" + assert submit.button_style == "primary" + assert submit.disabled is True # no notebook path yet (S005) + assert panel._controls["refresh"].description == "Refresh" + assert "taugrid-panel" in panel._controls["chart"].value + + +def test_submit_button_enables_once_notebook_path_is_set(tmp_path): + panel, tree, _ = _panel_with_button() + assert panel._controls["submit"].disabled is True + assert "notebook path not resolved" in panel._controls["status"].value + + panel.set_notebook_path(_real_notebook(tmp_path)) + assert panel._controls["submit"].disabled is False + assert "analysis.ipynb" in panel._controls["status"].value + + +def test_clicking_submit_runs_the_full_chain(tmp_path): + panel, tree, fake = _panel_with_button(cluster=_cluster_doc()) + panel.set_notebook_path(_real_notebook(tmp_path)) + with tempfile.TemporaryDirectory() as d: + # The click handler creates its own staging dir via the panel's factory; + # point that factory at this temp dir so cleanup is deterministic. + panel._default_staging_dir = lambda: Path(d) + # Invoke the same handler the button's on_click registered. + handle = panel._on_submit_clicked() + assert handle.name == "demo" and handle.kind == "RayJob" + assert panel.staged.notebook_path.exists() + staged_dir = panel.staged.notebook_path.parent + assert "_tau_runner.py" in {p.name for p in staged_dir.iterdir()} + body = fake.created[0]["body"] + assert body["metadata"]["labels"][TAU_QUEUE_LABEL] == "research-gpu" + # After submit the button stays enabled (re-submission is allowed) and the + # status line switches into run view. + assert panel._controls["submit"].disabled is False + assert "demo" in panel._controls["status"].value + + +def test_clicking_submit_with_no_notebook_raises_instead_of_misrendering(): + panel, tree, fake = _panel_with_button() + with pytest.raises(ValueError): + panel._on_submit_clicked() + assert fake.created == [] + + +def test_refresh_button_repaints_status_and_chart(tmp_path): + panel, tree, _ = _panel_with_button(cluster=_cluster_doc()) + panel.set_notebook_path(_real_notebook(tmp_path)) + before = panel._controls["status"].value + panel._on_refresh_clicked() + assert panel._controls["status"].value == before + assert "taugrid-panel" in panel._controls["chart"].value + + +def test_button_flow_uses_caller_profile_and_queue(tmp_path): + fake = FakeCustomApi() + panel = TauGridPanel(namespace="ray", run_name="demo") + panel.set_notebook_path(_real_notebook(tmp_path)) + panel.build() + with tempfile.TemporaryDirectory() as d: + handle = panel.submit( + notebook=panel.notebook_path, + profile=Profile(name="cpu", workers=1, gpus_per_worker=0), + queue="cpu-queue", + staging_dir=Path(d), + backend=KubernetesBackend(custom_objects=fake), + ) + assert handle.name == "demo" + body = fake.created[0]["body"] + assert body["metadata"]["labels"][TAU_QUEUE_LABEL] == "cpu-queue" \ No newline at end of file diff --git a/sdk/python/python/tests/test_widgets_core.py b/sdk/python/python/tests/test_widgets_core.py new file mode 100644 index 00000000..eef44dac --- /dev/null +++ b/sdk/python/python/tests/test_widgets_core.py @@ -0,0 +1,202 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Offline tests for the notebook widget core (backend + renderer + profile).""" + +import json +import tempfile +from pathlib import Path + +import pytest + +from tau._backend import KubernetesBackend +from tau._notebook_pkg import PayloadTooLarge, package +from tau._profile import NoTauClusterFound, resolve +from tau._render import Profile, UnsupportedShape, refuse, render_rayjob + +TAU_KEY = "kueue.x-k8s.io/queue-name" + +VALID_NB = json.dumps({ + "nbformat": 4, + "nbformat_minor": 5, + "metadata": {"kernelspec": {"language": "python", "name": "python3"}, "language_info": {"name": "python"}}, + "cells": [ + {"cell_type": "code", "id": "c0", "metadata": {}, "outputs": [], "execution_count": None, "source": ["print(1)"]}, + ], +}).encode() + + +class FakeCustomApi: + """Records create_namespaced_custom_object calls for the backend test.""" + + def __init__(self) -> None: + self.created: list[dict] = [] + + def create_namespaced_custom_object(self, **kwargs): + self.created.append(kwargs) + return {"metadata": {"name": kwargs.get("body", {}).get("metadata", {}).get("name")}} + + +def test_import_tau_does_not_pull_kubernetes(): + import sys + + for mod in list(sys.modules): + if mod.startswith("kubernetes"): + del sys.modules[mod] + assert "kubernetes" not in sys.modules + + +def test_render_rayjob_kueue_contract(): + job = render_rayjob( + name="demo", + namespace="ray", + queue="research-gpu", + profile=Profile(name="p1", workers=2, gpus_per_worker=8, num_cpus_per_worker=16), + entrypoint="python _tau_runner.py", + ) + assert job["apiVersion"] == "ray.io/v1" + assert job["kind"] == "RayJob" + assert job["spec"]["suspend"] is True + assert "managedBy" not in job["spec"] + assert job["metadata"]["labels"][TAU_KEY] == "research-gpu" + head = job["spec"]["rayClusterSpec"]["headGroupSpec"] + assert head["rayStartParams"]["num-gpus"] == "0" + assert head["rayStartParams"]["num-cpus"] == "0" + workers = job["spec"]["rayClusterSpec"]["workerGroupSpecs"][0] + assert workers["replicas"] == 2 + assert workers["rayStartParams"]["num-gpus"] == "8" + assert workers["template"]["spec"]["containers"][0]["resources"]["requests"]["nvidia.com/gpu"] == "8" + + +def test_render_rayjob_no_gpu_when_profile_zero(): + job = render_rayjob( + name="cpu", namespace="ns", queue="q", profile=Profile(name="cpu", workers=1, gpus_per_worker=0), + entrypoint="cmd", + ) + wg = job["spec"]["rayClusterSpec"]["workerGroupSpecs"][0] + req = wg["template"]["spec"]["containers"][0]["resources"]["requests"] + assert "nvidia.com/gpu" not in req + assert wg["rayStartParams"]["num-gpus"] == "0" + + +def test_refuse_unsupported_shapes(): + with pytest.raises(UnsupportedShape): + refuse(archives=True) + with pytest.raises(UnsupportedShape): + refuse(chained=True) + with pytest.raises(UnsupportedShape): + refuse(offload=True) + refuse() # non-refusal is a no-op + + +def test_kubernetes_backend_submits_rayjob(): + fake = FakeCustomApi() + backend = KubernetesBackend(custom_objects=fake) + manifest = render_rayjob( + name="x", namespace="ns", queue="q", profile=Profile(name="p", workers=1), + entrypoint="cmd", + ) + backend.submit_rayjob(manifest, namespace="ns") + assert len(fake.created) == 1 + assert fake.created[0]["body"]["kind"] == "RayJob" + assert fake.created[0]["plural"] == "rayjobs" + + +def test_profile_resolution(): + cluster = { + "spec": { + "workspaceDefaults": {"defaultQueue": "research-gpu"}, + "workloadProfiles": [ + {"name": "training-8gpu", "status": {"state": "ready"}, + "compute": {"workerReplicas": 4, "gpusPerWorker": 8}}, + {"name": "training-1gpu", "status": {"state": "ready"}, + "compute": {"workerReplicas": 1, "gpusPerWorker": 1}}, + {"name": "broken", "status": {"state": "notready"}, + "compute": {"workerReplicas": 2, "gpusPerWorker": 2}}, + ], + } + } + profiles, queue = resolve(cluster) + assert queue == "research-gpu" + names = {p.name for p in profiles} + assert names == {"training-8gpu", "training-1gpu"} + eight = next(p for p in profiles if p.name == "training-8gpu") + assert eight.workers == 4 and eight.gpus_per_worker == 8 + + +def test_resolve_no_cluster_raises(): + with pytest.raises(NoTauClusterFound): + resolve({}) + + +def test_package_is_self_contained_and_capped(): + with tempfile.TemporaryDirectory() as d: + payload = package(VALID_NB, staging_dir=Path(d)) + assert payload.notebook_path.exists() + assert payload.runner_path.exists() + assert "analysis.ipynb" in payload.notebook_path.name + with pytest.raises(PayloadTooLarge): + package(VALID_NB, staging_dir=Path(d), input_cap=10) + + +def test_runner_forwards_iopub_without_starting_kernel(tmp_path, monkeypatch, capsys): + import runpy + import sys + from types import SimpleNamespace + + messages = [ + {"msg_type": "stream", "content": {"name": "stdout", "text": "step=1 loss=0.5\n"}}, + {"msg_type": "stream", "content": {"name": "stderr", "text": "diagnostic\n"}}, + {"msg_type": "display_data", "content": {"data": {"text/plain": "not a stream"}}}, + ] + handled = [] + written = [] + + class FakeExecutor: + def __init__(self, timeout): + assert timeout == 600 + + def process_message(self, message, cell, index): + handled.append(message) + return "preserved" + + def preprocess(self, notebook, resources): + for message in messages: + assert self.process_message(message, {}, 0) == "preserved" + + monkeypatch.setitem(sys.modules, "nbconvert", SimpleNamespace(preprocessors=SimpleNamespace(ExecutePreprocessor=FakeExecutor))) + monkeypatch.setitem(sys.modules, "nbformat", SimpleNamespace( + read=lambda *args, **kwargs: {}, write=lambda *args, **kwargs: written.append(args))) + monkeypatch.setattr(sys, "argv", ["_tau_runner.py"]) + payload = package(VALID_NB, staging_dir=tmp_path) + runner = runpy.run_path(str(payload.runner_path)) + assert runner["main"]() == 0 + output = capsys.readouterr() + assert "step=1 loss=0.5\n" in output.out + assert output.err == "diagnostic\n" + assert "not a stream" not in output.out + assert handled == messages and len(written) == 1 + + +def test_package_does_not_modify_source(): + import tempfile as _t + + with _t.TemporaryDirectory() as d: + noted = Path(d) / "src.ipynb" + original = VALID_NB + noted.write_bytes(original) + before = noted.read_bytes() + staging = Path(d) / "stage" + package(noted.read_bytes(), staging_dir=staging) + assert noted.read_bytes() == before + + +@pytest.mark.parametrize("kwargs", [ + {"workers": 0}, {"workers": True}, {"workers": 1.5}, + {"gpus_per_worker": -1}, {"gpus_per_worker": True}, + {"num_cpus_per_worker": float("nan")}, {"num_cpus_per_worker": 0}, + {"num_cpus_per_worker": True}, {"node_selector": {"pool": 7}}, +]) +def test_explicit_profile_rejects_invalid_resources(kwargs): + with pytest.raises(ValueError): + Profile(name="invalid", **kwargs) diff --git a/sdk/python/python/tests/test_widgets_embed.py b/sdk/python/python/tests/test_widgets_embed.py new file mode 100644 index 00000000..d1f97af1 --- /dev/null +++ b/sdk/python/python/tests/test_widgets_embed.py @@ -0,0 +1,336 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Offline tests for the TensorBoard-style embed module and its integrations. + +Everything here is local: a tiny stdlib HTTP server stands in for the portal +and the proxy binds an ephemeral port on 127.0.0.1. No cluster, no real portal, +and no external network are touched. +""" + +import json +import threading +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from tau.widgets.embed import ( + PortalProxy, + experiment_path, + iframe_html, + ray_dashboard_path, + run_view_path, + same_origin, +) +from tau.widgets.ipython import _parse_magic +from tau.widgets.panel import TauGridPanel +from tau.widgets.status import RunStatus + + +# --- fake portal upstream ------------------------------------------------- + + +class _FakePortalHandler(BaseHTTPRequestHandler): + """Serves one framed HTML page, one JSON path, and a 404 for anything else.""" + + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 - http.server API + self._respond("GET") + + def do_HEAD(self) -> None: # noqa: N802 - http.server API + self._respond("HEAD") + + def _respond(self, method: str) -> None: + if self.path == "/page": + body = b"portal page" + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'") + elif self.path == "/data.json": + body = json.dumps({"ok": True, "path": self.path}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + else: + body = b"not found" + self.send_response(404) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if method != "HEAD": + self.wfile.write(body) + + def log_message(self, *args: object) -> None: # keep pytest output clean + return + + +class _FakePortal: + """A local stand-in for the TauGrid portal, bound to an ephemeral port.""" + + def __init__(self) -> None: + self._server = ThreadingHTTPServer(("127.0.0.1", 0), _FakePortalHandler) + self._server.daemon_threads = True # type: ignore[attr-defined] + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + + @property + def base_url(self) -> str: + host, port = self._server.server_address[:2] + return f"http://{host}:{port}" + + def stop(self) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=5) + + def __enter__(self) -> "_FakePortal": + return self + + def __exit__(self, *exc: object) -> bool: + self.stop() + return False + + +# --- path builders -------------------------------------------------------- + + +def test_run_view_path_quotes_both_segments(): + assert run_view_path("ray", "my-job") == "/portal/runs/ray/my-job" + # A slash and a space must both be escaped, not treated as path structure. + assert run_view_path("my ns", "a/b") == "/portal/runs/my%20ns/a%2Fb" + + +def test_ray_dashboard_path_quotes_segments(): + assert ray_dashboard_path("ray", "cluster") == "/api/portal/ray/proxy/ray/cluster/" + assert ray_dashboard_path("my ns", "cluster/1") == "/api/portal/ray/proxy/my%20ns/cluster%2F1/" + + +def test_experiment_path_quotes_run_id(): + assert experiment_path("job-123") == "/stellar?target=job-123" + assert experiment_path("job id/1") == "/stellar?target=job%20id%2F1" + + +# --- same_origin ---------------------------------------------------------- + + +def test_same_origin_true_for_matching_scheme_host_and_default_port(): + assert same_origin("http://portal.example.com/a", "http://portal.example.com/b") is True + assert same_origin("http://portal.example.com:80/a", "http://portal.example.com/b") is True + assert same_origin("https://portal.example.com/a", "https://portal.example.com:443/b") is True + + +def test_same_origin_false_for_scheme_host_or_port_mismatch(): + assert same_origin("http://portal.example.com", "https://portal.example.com") is False + assert same_origin("http://portal.example.com:8080", "http://portal.example.com") is False + assert same_origin("http://a.example.com", "http://b.example.com") is False + + +# --- iframe_html ---------------------------------------------------------- + + +def test_iframe_html_escapes_url_and_title(): + url = 'http://portal.example.com/x">' + title = '"run' + out = iframe_html(url, height=500, title=title) + assert "height:500px" in out + assert "