From c6e33bca8a3e48cb62d191daebeb4a6849de810c Mon Sep 17 00:00:00 2001 From: Kingston Date: Sun, 23 Aug 2026 20:39:42 -0700 Subject: [PATCH] feat(ui): one canvas over an ingest run, drilled down five levels The workspace UI as a single screen people already know how to read: a left-to-right node graph with a breadcrumb, an inspector, and one thing to learn -- a node with a chevron opens. Two branches leave the run, because a run has two things worth following: run -> stage -> steps the orchestration, and the code inside it run -> episodes -> episode the data it produced Every judgement about what is honest to draw lives in one pure function, src/canvas/buildGraph.ts, tested without a browser. The rule it keeps is that an edge means a real dependency: - The run level merges each stage's gate and trigger into one node and drops the shortcuts the stage chain already implies, turning a nine-rank ribbon into a five-node line. Both task ids, states and durations stay on the node, so the summary hides nothing. - The stage level expands the mapped process_batch into one node per batch while that is readable, and into one stacked node carrying the state split once it is not. The stacked node takes the WORST state in the fan, so one failure among a hundred successes is still what you see. - The steps level draws a tier as a column and arrows only at the boundaries that are real, because the server is explicit that registered steps have no dependency edges on each other. - The episodes branch hangs off the RUN, not off a stage, and filters on every stage run id at once. Measured, not assumed: the catalog's episodes view is one row per episode, so a per-stage question answers 0 for every stage the same ingest later superseded. src/apiSchema.ts is generated from the server's own OpenAPI declaration and CI fails if it goes stale, so nothing in the client hand-copies a payload field. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 31 + docs/SERVE.md | 5 + ui/.gitignore | 3 + ui/README.md | 99 ++ ui/biome.json | 35 + ui/index.html | 37 + ui/package.json | 38 + ui/pnpm-lock.yaml | 1613 +++++++++++++++++++++ ui/pnpm-workspace.yaml | 7 + ui/scripts/dump-openapi.py | 27 + ui/src/App.tsx | 446 ++++++ ui/src/api.ts | 167 +++ ui/src/apiSchema.ts | 2245 ++++++++++++++++++++++++++++++ ui/src/canvas/CanvasNodeView.tsx | 47 + ui/src/canvas/buildGraph.test.ts | 670 +++++++++ ui/src/canvas/buildGraph.ts | 1079 ++++++++++++++ ui/src/canvas/focus.ts | 82 ++ ui/src/canvas/layout.test.ts | 82 ++ ui/src/canvas/layout.ts | 104 ++ ui/src/main.tsx | 31 + ui/src/styles.css | 482 +++++++ ui/src/tones.ts | 76 + ui/tsconfig.json | 21 + ui/vite.config.ts | 13 + 24 files changed, 7440 insertions(+) create mode 100644 ui/.gitignore create mode 100644 ui/README.md create mode 100644 ui/biome.json create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/pnpm-lock.yaml create mode 100644 ui/pnpm-workspace.yaml create mode 100644 ui/scripts/dump-openapi.py create mode 100644 ui/src/App.tsx create mode 100644 ui/src/api.ts create mode 100644 ui/src/apiSchema.ts create mode 100644 ui/src/canvas/CanvasNodeView.tsx create mode 100644 ui/src/canvas/buildGraph.test.ts create mode 100644 ui/src/canvas/buildGraph.ts create mode 100644 ui/src/canvas/focus.ts create mode 100644 ui/src/canvas/layout.test.ts create mode 100644 ui/src/canvas/layout.ts create mode 100644 ui/src/main.tsx create mode 100644 ui/src/styles.css create mode 100644 ui/src/tones.ts create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c2a20d..02bf87e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,37 @@ jobs: - name: Tests run: uv run pytest -q + ui: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Enable pnpm via corepack (Node is preinstalled on the runner) + # No version here: ui/package.json's "packageManager" field pins it, so + # the version has one owner and corepack reads it from there. + run: corepack enable + - name: Install + run: pnpm install --frozen-lockfile + - name: Lint, typecheck, and test + run: pnpm check + - name: Build + run: pnpm build + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.11" + - name: Verify src/apiSchema.ts still matches the server's OpenAPI schema + # The browser's types are generated from the server's own declaration. + # A contract change that lands without regenerating puts them silently + # out of step again, which is exactly what generating them replaced. + run: | + pnpm gen:api + if ! git diff --exit-code src/apiSchema.ts; then + echo "src/apiSchema.ts is stale: run \`pnpm gen:api\` and commit the result." >&2 + exit 1 + fi + links: runs-on: ubuntu-latest steps: diff --git a/docs/SERVE.md b/docs/SERVE.md index 1954e10..2d44fe5 100644 --- a/docs/SERVE.md +++ b/docs/SERVE.md @@ -16,6 +16,11 @@ this package. The server ships no frontend of its own; point `HFLOW_UI_ASSETS` at a directory containing an `index.html` to serve one, or install a wheel that packages assets under `hflow_server/static/`. +One such client lives in this repo at [`ui/`](../ui/README.md): a single canvas +that draws an ingest run and drills from the run into a stage, into the steps +that run inside a batch, and into the episodes that run recorded. It is built +separately (`cd ui && pnpm build`) and served through `HFLOW_UI_ASSETS`. + It ships as a separate package, `hflow-server`, on purpose: pipeline workers install the `hflow` wheel into every task venv, and they should never carry a web server. **It is not published to PyPI yet** -- until the first release, diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..e5537be --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.local diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..2d11aab --- /dev/null +++ b/ui/README.md @@ -0,0 +1,99 @@ +# hflow workspace UI + +One canvas over an ingest run. It draws the run's graph, and every node you can +open leads one level further in: + +``` +run -> stage -> steps the orchestration, and the code inside it +run -> episodes -> episode the data that run produced +``` + +- **run** -- the ingest DAG as a chain: resolve the profile, then each stage. +- **stage** -- one stage's sub-DAG: plan the batches, fan `process_batch` out + over them, close on a budget gate. +- **steps** -- what one `process_batch` does to each episode: the pipeline's + registered checks and enrichments, plus the engine's own work. +- **episodes** -- the episodes whose current catalog row came out of this run. +- **episode** -- every check recorded for it, with its verdict, its gate, and + the measurements it was judged on. + +A node with a `>` opens; the inspector on the right explains whatever is +selected; Escape walks back out. + +This is a **client of the `hflow-server` REST API** and holds no knowledge the +server does not serve. It is not published as a package: build it and point the +server at the output. + +## Running it + +```bash +pnpm install +pnpm dev # http://localhost:5173, proxying /api to :4356 +``` + +`pnpm dev` needs a server to talk to. In another terminal: + +```bash +uv run hflow serve --no-browser --pipeline path/to/pipeline.py +``` + +`--pipeline` is what makes the **steps** level non-empty: without it the server +does not know which checks run inside a batch, and the canvas says so rather +than guessing. + +To serve the built bundle from the API server itself: + +```bash +pnpm build +HFLOW_UI_ASSETS=$PWD/dist uv run hflow serve --no-browser +``` + +## Checks + +```bash +pnpm check # tsc --noEmit, biome check, vitest +pnpm format # biome check --write +pnpm gen:api # regenerate src/apiSchema.ts from the server's OpenAPI schema +``` + +CI runs `pnpm check`, `pnpm build`, and re-runs `pnpm gen:api` to verify the +generated types are not stale. + +## How it is put together + +Five files carry the whole thing, and only one of them has decisions in it: + +| file | what it owns | +| --- | --- | +| `src/canvas/buildGraph.ts` | focus + server payloads -> nodes and edges. Pure, and where every judgement about what is honest to draw lives. | +| `src/canvas/focus.ts` | where the canvas is pointed, and the breadcrumb derived from it | +| `src/canvas/layout.ts` | dagre positions, left to right | +| `src/api.ts` | every request, typed against the generated schema | +| `src/App.tsx` | the screen, and what a click does | + +`src/apiSchema.ts` is **generated** by `pnpm gen:api` from the server's own +OpenAPI declaration -- do not hand-edit it. Nothing else in `src/` restates a +payload field name, so a contract change surfaces as a TypeScript error rather +than as an `undefined` at runtime. + +`buildGraph` is tested (`pnpm test`) because it is pure and because its rules +matter: **an edge means a real dependency.** The server is explicit that a +pipeline's registered steps have no dependency edges on each other, so the +steps level groups them into tier columns and draws arrows only at the +boundaries that are real. + +`src/tones.ts` is the one owner of "what colour does this outcome read as", for +two separate vocabularies that must not be confused: Airflow's task states and +hflow's own recorded check statuses. + +## Constraints it keeps + +- **No network beyond the API.** No CDN, no fonts, no telemetry. The workspace + server makes an offline promise (`docs/SERVE.md`, "Trust posture") and a + frontend that phones home would break it. +- **No theme toggle.** Both palettes are in `styles.css` under + `prefers-color-scheme`, so there is nothing stored and nothing to keep in + sync with a pre-paint script. +- **TypeScript stays on 5.x.** `openapi-typescript` drives the TypeScript + compiler API through `ts.factory`, which TypeScript 7's native port does not + expose; on 7 `pnpm gen:api` dies before emitting anything. diff --git a/ui/biome.json b/ui/biome.json new file mode 100644 index 0000000..a03c8fb --- /dev/null +++ b/ui/biome.json @@ -0,0 +1,35 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "includes": ["**", "!dist", "!node_modules"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..991ab93 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,37 @@ + + + + + + + + + + HFlow + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..cd1b389 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,38 @@ +{ + "name": "hflow-workspace-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Workspace UI for hflow: one canvas over the ingest graph. A client of the hflow-server JSON API.", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "packageManager": "pnpm@11.22.0", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run", + "check": "tsc --noEmit && biome check . && vitest run", + "format": "biome check --write .", + "gen:api": "uv run --project .. python scripts/dump-openapi.py > .openapi.json && openapi-typescript .openapi.json -o src/apiSchema.ts && rm -f .openapi.json && biome check --write src/apiSchema.ts" + }, + "dependencies": { + "@dagrejs/dagre": "^3.1.1", + "@tanstack/react-query": "^5.62.0", + "@xyflow/react": "^12.11.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.0", + "openapi-typescript": "^7.13.0", + "typescript": "^5.9.3", + "vite": "^8.2.2", + "vitest": "^4.1.11" + } +} diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml new file mode 100644 index 0000000..c38190d --- /dev/null +++ b/ui/pnpm-lock.yaml @@ -0,0 +1,1613 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dagrejs/dagre': + specifier: ^3.1.1 + version: 3.1.1 + '@tanstack/react-query': + specifier: ^5.62.0 + version: 5.102.0(react@19.2.8) + '@xyflow/react': + specifier: ^12.11.0 + version: 12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.0 + version: 19.2.8 + react-dom: + specifier: ^19.2.0 + version: 19.2.8(react@19.2.8) + devDependencies: + '@biomejs/biome': + specifier: ^2.2.0 + version: 2.5.10 + '@types/react': + specifier: ^19.2.0 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.1.0 + version: 6.1.0(vite@8.2.2(esbuild@0.28.2)) + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.2 + version: 8.2.2(esbuild@0.28.2) + vitest: + specifier: ^4.1.11 + version: 4.1.11(vite@8.2.2(esbuild@0.28.2)) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.5.10': + resolution: {integrity: sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.10': + resolution: {integrity: sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.10': + resolution: {integrity: sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.10': + resolution: {integrity: sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.10': + resolution: {integrity: sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.10': + resolution: {integrity: sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.10': + resolution: {integrity: sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.10': + resolution: {integrity: sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.10': + resolution: {integrity: sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@dagrejs/dagre@3.1.1': + resolution: {integrity: sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A==} + + '@dagrejs/graphlib@4.0.5': + resolution: {integrity: sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.19': + resolution: {integrity: sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@rolldown/binding-android-arm-eabi@1.2.5': + resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.5': + resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.5': + resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.5': + resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.5': + resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.5': + resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.5': + resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.5': + resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.5': + resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.5': + resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tanstack/query-core@5.102.0': + resolution: {integrity: sha512-tvBzr11Q7StuMCEsIJdqX8TAWt6WZIzfw/yrSAjObZDerwTTPeCxeLXN2R8ZSn4ZxFpQV819Xn8QmoO+vtnDvw==} + + '@tanstack/react-query@5.102.0': + resolution: {integrity: sha512-0GyVyEcGt9M7jHPCua16hNVtstUXE2R4HsnNubFYcDs7DJaLzh1dSiXsADDU/cNn/SqrLfa0vRKyjUmbx4rZLA==} + peerDependencies: + react: ^18 || ^19 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitejs/plugin-react@6.1.0': + resolution: {integrity: sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + '@xyflow/react@12.11.3': + resolution: {integrity: sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.80': + resolution: {integrity: sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.5: + resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@biomejs/biome@2.5.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.10 + '@biomejs/cli-darwin-x64': 2.5.10 + '@biomejs/cli-linux-arm64': 2.5.10 + '@biomejs/cli-linux-arm64-musl': 2.5.10 + '@biomejs/cli-linux-x64': 2.5.10 + '@biomejs/cli-linux-x64-musl': 2.5.10 + '@biomejs/cli-win32-arm64': 2.5.10 + '@biomejs/cli-win32-x64': 2.5.10 + + '@biomejs/cli-darwin-arm64@2.5.10': + optional: true + + '@biomejs/cli-darwin-x64@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-arm64@2.5.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.10': + optional: true + + '@biomejs/cli-linux-x64@2.5.10': + optional: true + + '@biomejs/cli-win32-arm64@2.5.10': + optional: true + + '@biomejs/cli-win32-x64@2.5.10': + optional: true + + '@dagrejs/dagre@3.1.1': + dependencies: + '@dagrejs/graphlib': 4.0.5 + + '@dagrejs/graphlib@4.0.5': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@oxc-project/types@0.146.0': {} + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.19(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.3.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@rolldown/binding-android-arm-eabi@1.2.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tanstack/query-core@5.102.0': {} + + '@tanstack/react-query@5.102.0(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.102.0 + react: 19.2.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.1.0(vite@8.2.2(esbuild@0.28.2))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2(esbuild@0.28.2) + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2(esbuild@0.28.2))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(esbuild@0.28.2) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@xyflow/react@12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@xyflow/system': 0.0.80 + classcat: 5.0.5 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + zustand: 4.5.7(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.80': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + chai@6.2.2: {} + + change-case@5.4.4: {} + + classcat@5.0.5: {} + + colorette@1.4.0: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + optional: true + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + index-to-position@1.2.0: {} + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + obug@2.1.4: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.19(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pluralize@8.0.0: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react@19.2.8: {} + + require-from-string@2.0.2: {} + + rolldown@1.2.5: + dependencies: + '@oxc-project/types': 0.146.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.5 + '@rolldown/binding-android-arm64': 1.2.5 + '@rolldown/binding-darwin-arm64': 1.2.5 + '@rolldown/binding-darwin-x64': 1.2.5 + '@rolldown/binding-freebsd-x64': 1.2.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.5 + '@rolldown/binding-linux-arm64-gnu': 1.2.5 + '@rolldown/binding-linux-arm64-musl': 1.2.5 + '@rolldown/binding-linux-ppc64-gnu': 1.2.5 + '@rolldown/binding-linux-s390x-gnu': 1.2.5 + '@rolldown/binding-linux-x64-gnu': 1.2.5 + '@rolldown/binding-linux-x64-musl': 1.2.5 + '@rolldown/binding-openharmony-arm64': 1.2.5 + '@rolldown/binding-win32-arm64-msvc': 1.2.5 + '@rolldown/binding-win32-x64-msvc': 1.2.5 + + scheduler@0.27.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + supports-color@10.2.2: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + uri-js-replace@1.0.1: {} + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + vite@8.2.2(esbuild@0.28.2): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + esbuild: 0.28.2 + fsevents: 2.3.3 + + vitest@4.1.11(vite@8.2.2(esbuild@0.28.2)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(esbuild@0.28.2)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(esbuild@0.28.2) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + + zustand@4.5.7(@types/react@19.2.18)(react@19.2.8): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + react: 19.2.8 diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml new file mode 100644 index 0000000..280f65f --- /dev/null +++ b/ui/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +# ui/ is a standalone pnpm project; this file also carries pnpm settings. +packages: + - "." + +# esbuild's postinstall validates its platform binary; allow it to run. +allowBuilds: + esbuild: true diff --git a/ui/scripts/dump-openapi.py b/ui/scripts/dump-openapi.py new file mode 100644 index 0000000..9760d7f --- /dev/null +++ b/ui/scripts/dump-openapi.py @@ -0,0 +1,27 @@ +"""Print the workspace server's OpenAPI schema to stdout. + +The generated TypeScript in ``src/apiSchema.ts`` is derived from this, so the +browser's idea of every payload comes from the server's own declaration +rather than from a hand-copied interface. Run it through ``pnpm gen:api``. + +No server is started and no workspace is read: ``create_app`` builds the +routes from ``hflow_server._contract``, and the schema is a property of those +routes. The data root only has to exist, so a temporary directory does. +""" + +import json +import sys +import tempfile +from pathlib import Path + +from hflow_server import ServerSettings, create_app + +with tempfile.TemporaryDirectory() as temporary_root: + # assets_dir is pinned empty so the schema never depends on whether + # someone happens to have a frontend built locally. + settings = ServerSettings( + data_root=temporary_root, + assets_dir=Path(temporary_root) / "no-assets", + ) + json.dump(create_app(settings).openapi(), sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..1b4faba --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,446 @@ +// The whole screen: a breadcrumb, one canvas, one inspector. +// +// Everything the canvas draws comes from buildGraph, and everything it knows +// comes from the hooks in api.ts, so this file only decides what is on screen +// and what a click does. + +import { + Background, + Controls, + ReactFlow, + ReactFlowProvider, + useReactFlow, + useStore, +} from "@xyflow/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type RuntimeRunSummary, + useEpisodeDossier, + usePipelineGraph, + useRunEpisodes, + useRunGraph, + useRuntimeRuns, + useRuntimeStatus, + useWorkspaceConfig, +} from "./api"; +import { + buildGraph, + type CanvasGraph, + type CanvasNodeData, + stageRunIds, +} from "./canvas/buildGraph"; +import { CANVAS_NODE_TYPES, type FlowNode } from "./canvas/CanvasNodeView"; +import { + type Breadcrumb, + breadcrumbs, + type CanvasFocus, + parentFocus, + RUN_FOCUS, +} from "./canvas/focus"; +import { graphBounds, layoutGraph } from "./canvas/layout"; + +/** What each level is, in one line, so the canvas explains itself. */ +const LEVEL_CAPTIONS: Record = { + run: "The ingest DAG. Each stage is gated by the run profile, then triggered in order.", + stage: + "Inside one stage: batches are planned, process_batch fans out over them, and a " + + "budget gate closes the stage.", + steps: "What one process_batch does to each episode in its batch.", + episodes: "The episodes whose current catalog row came out of this run.", + episode: "Every check recorded for this episode, with its verdict and the gate it was judged on.", +}; + +export function App() { + return ( + + + + ); +} + +function Workspace() { + const [focus, setFocus] = useState(RUN_FOCUS); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedRunId, setSelectedRunId] = useState(null); + + const config = useWorkspaceConfig(); + const runtimeAddressed = config.data?.capabilities.runtime === true; + const status = useRuntimeStatus(); + const runs = useRuntimeRuns(runtimeAddressed); + const pipelineGraph = usePipelineGraph(); + const runGraph = useRunGraph(selectedRunId); + + // The newest run, once, so the canvas opens on something real instead of on + // an empty picker. Later refetches must not yank the selection off whatever + // the user chose, so this only fires while nothing is selected. + const newestRunId = runs.data?.runs[0]?.dag_run_id ?? null; + useEffect(() => { + if (selectedRunId === null && newestRunId !== null) setSelectedRunId(newestRunId); + }, [selectedRunId, newestRunId]); + + // Every stage run of the selected master run, which is the filter the + // episodes branch is scoped by (see stageRunIds and useRunEpisodes for why it + // is the union and not one stage). + const episodes = useRunEpisodes(stageRunIds(runGraph.data ?? null)); + const dossier = useEpisodeDossier(focus.level === "episode" ? focus.episodeId : null); + + const graph = useMemo(() => { + if (pipelineGraph.data === undefined) return null; + return buildGraph({ + focus, + pipeline: pipelineGraph.data, + run: runGraph.data ?? null, + episodes: episodes.data ?? null, + dossier: dossier.data ?? null, + }); + }, [focus, pipelineGraph.data, runGraph.data, episodes.data, dossier.data]); + + const navigate = useCallback((next: CanvasFocus) => { + setFocus(next); + setSelectedNodeId(null); + }, []); + + // Escape walks back out, the way every drill-down does. + const parent = parentFocus(focus); + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape" && parent !== null) navigate(parent); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [parent, navigate]); + + const selectedNode = graph?.nodes.find((node) => node.id === selectedNodeId)?.data ?? null; + + return ( +
+
+ HFlow + { + setSelectedRunId(runId); + navigate(RUN_FOCUS); + }} + disabled={!runtimeAddressed} + /> + + + {runtimeAddressed + ? status.data?.available === true + ? "runtime up" + : "runtime down" + : "no runtime"} + + {config.data === undefined ? null : ( + + {config.data.data_root} + + )} +
+ +
+
+ +

{LEVEL_CAPTIONS[focus.level]}

+ {(graph?.notices ?? []).map((notice) => ( +

+ {notice} +

+ ))} + +
+ +
+
+ ); +} + +function CrumbButton({ + crumb, + isLast, + onSelect, +}: { + crumb: Breadcrumb; + isLast: boolean; + onSelect: (focus: CanvasFocus) => void; +}) { + return ( + <> + + {isLast ? null : /} + + ); +} + +function RunPicker({ + runs, + selectedRunId, + onSelect, + disabled, +}: { + runs: readonly RuntimeRunSummary[]; + selectedRunId: string | null; + onSelect: (runId: string) => void; + disabled: boolean; +}) { + if (disabled) { + return ( + no runtime addressed — showing the DAG's shape + ); + } + if (runs.length === 0) { + return no runs yet; + } + return ( + + ); +} + +function CanvasSurface({ + graph, + error, + selectedNodeId, + onSelect, + onDrill, +}: { + graph: CanvasGraph | null; + error: Error | null; + selectedNodeId: string | null; + onSelect: (nodeId: string) => void; + onDrill: (focus: CanvasFocus) => void; +}) { + if (error !== null) { + return ( +
+

Could not draw this.

+

{error.message}

+
+ ); + } + if (graph === null) { + return
Loading the topology...
; + } + if (graph.nodes.length === 0) { + return ( +
{graph.emptyMessage ?? "Nothing to draw."}
+ ); + } + return ( +
+ +
+ ); +} + +/** + * The flow itself, split out so it MOUNTS WITH ReactFlow. + * + * The framing effect below has to run against a mounted flow, and the + * message states above unmount it -- so an effect living beside them would + * fire while there is no canvas to frame. + */ +function CanvasFlow({ + graph, + selectedNodeId, + onSelect, + onDrill, +}: { + graph: CanvasGraph; + selectedNodeId: string | null; + onSelect: (nodeId: string) => void; + onDrill: (focus: CanvasFocus) => void; +}) { + const { fitBounds } = useReactFlow(); + // The flow's own measured container. React Flow fills it from a + // ResizeObserver, which fires AFTER the render that changed the layout, so + // framing without watching this uses the previous level's canvas height -- + // and clips the new graph by however much the notices above it grew. + const flowSize = useStore((state) => `${Math.round(state.width)}x${Math.round(state.height)}`); + const positioned = useMemo(() => layoutGraph(graph.nodes, graph.edges), [graph]); + const flowNodes = useMemo( + () => + positioned.map((node) => ({ + id: node.id, + type: "canvas" as const, + position: { x: node.position.x, y: node.position.y }, + data: node.data, + selected: node.id === selectedNodeId, + draggable: false, + // The box goes on `style`, not on the node's own width/height fields. + // Setting those tells React Flow the size is already known, which + // leaves `measured` unset -- and useNodesInitialized never turns true, + // so nothing ever frames the graph. Styling it lets React Flow measure + // the wrapper it just sized, which is the same number either way. + style: { width: node.width, height: node.height }, + })), + [positioned, selectedNodeId], + ); + const flowEdges = useMemo(() => { + // React Flow warns about an edge naming a node that is not on the canvas, + // so a rewired level's leftover edge is dropped rather than handed over. + const drawnNodeIds = new Set(positioned.map((node) => node.id)); + return graph.edges + .filter((edge) => drawnNodeIds.has(edge.source) && drawnNodeIds.has(edge.target)) + .map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + type: "smoothstep" as const, + label: edge.label ?? undefined, + className: edge.dashed ? "edge-dashed" : undefined, + })); + }, [graph, positioned]); + + // Re-frame whenever the graph's own BOX changes: a new level, or a fan that + // just expanded, has nothing to do with the previous viewport. Deliberately + // not fitView: that one needs every node measured in the DOM first, so on a + // first paint it silently frames nothing. The box is already known here. + // + // Keyed on the box and not on the graph object, because the 4s poll rebuilds + // an identical graph and re-framing on every tick would pan under the reader. + const bounds = useMemo(() => graphBounds(positioned), [positioned]); + const lastFramedBox = useRef(""); + useEffect(() => { + if (bounds === null) return; + const box = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}@${flowSize}`; + if (box === lastFramedBox.current) return; + lastFramedBox.current = box; + // One frame later: the flow measures its own container on mount, and + // framing against a zero-sized container would land nowhere. + const framed = requestAnimationFrame(() => fitBounds(bounds, { padding: 0.15, duration: 200 })); + return () => cancelAnimationFrame(framed); + }, [bounds, flowSize, fitBounds]); + + return ( + onSelect(node.id)} + onNodeDoubleClick={(_event, node) => { + const drillTo = (node.data as CanvasNodeData).drillTo; + if (drillTo !== null) onDrill(drillTo); + }} + proOptions={{ hideAttribution: false }} + minZoom={0.15} + maxZoom={1.6} + > + + + + ); +} + +function Inspector({ + node, + onDrill, +}: { + node: CanvasNodeData | null; + onDrill: (focus: CanvasFocus) => void; +}) { + if (node === null) { + return ( +
+

Select a node to see what it is.

+

+ A node with a › has more inside it: open it from here, or double-click it. Escape + goes back out. +

+
+ ); + } + return ( + <> + {/* Tone on the heading, badges below it. The node's SHAPE was here once + and it told the reader nothing: "task" and "item" are this canvas's + own vocabulary, not facts about what they selected. */} +

{node.title}

+ {node.badges.length === 0 ? null : ( +
+ {node.badges.map((badge) => ( + + {badge} + + ))} +
+ )} + {node.subtitle === null ? null :

{node.subtitle}

} + +
+ {node.detail.map((line) => ( +
+
{line.label}
+
{line.value}
+
+ ))} +
+ + ); +} + +/** Its own component so the non-null focus is a narrowed value, not an assertion. */ +function DrillButton({ + drillTo, + onDrill, +}: { + drillTo: CanvasFocus | null; + onDrill: (focus: CanvasFocus) => void; +}) { + if (drillTo === null) return null; + return ( + + ); +} diff --git a/ui/src/api.ts b/ui/src/api.ts new file mode 100644 index 0000000..313752c --- /dev/null +++ b/ui/src/api.ts @@ -0,0 +1,167 @@ +// The only place this app talks to the server. Every payload type is an alias +// into src/apiSchema.ts, which `pnpm gen:api` regenerates from the server's own +// OpenAPI declaration -- so nothing here hand-copies a field name, and a +// contract change surfaces as a TypeScript error rather than as an undefined at +// runtime. + +import { useQuery } from "@tanstack/react-query"; +import type { components } from "./apiSchema"; + +type Schemas = components["schemas"]; + +export type Stage = Schemas["Stage"]; +export type DagTaskNode = Schemas["DagTaskNodePayload"]; +export type EpisodeCheckRun = Schemas["EpisodeCheckRunRecord"]; +export type EpisodeDossier = Schemas["EpisodeDossierResponse"]; +export type EpisodePage = Schemas["EpisodePageResponse"]; +export type PipelineEngineStep = Schemas["PipelineEngineStep"]; +export type PipelineGate = Schemas["PipelineGate"]; +export type PipelineGraph = Schemas["PipelineGraphResponse"]; +export type PipelineGraphStage = Schemas["PipelineGraphStage"]; +export type PipelineUserStep = Schemas["PipelineUserStep"]; +export type QuarantineGate = Schemas["QuarantineGate"]; +export type RunGraph = Schemas["RunGraphResponse"]; +export type RunGraphStage = Schemas["RunGraphStage"]; +export type RunTaskInstance = Schemas["RunTaskInstance"]; +export type RuntimeRunSummary = Schemas["RuntimeRunSummary"]; +export type RuntimeRuns = Schemas["RuntimeRunsResponse"]; +export type RuntimeStatus = Schemas["RuntimeStatusResponse"]; +export type WorkspaceConfig = Schemas["WorkspaceConfigResponse"]; + +/** A refused request, carrying the server's own detail string. */ +export class ApiError extends Error { + constructor( + readonly status: number, + detail: string, + ) { + super(detail); + this.name = "ApiError"; + } +} + +/** FastAPI answers a refusal with `detail`, either a string or a validation list. */ +function refusalDetail(body: unknown, status: number): string { + if (typeof body === "object" && body !== null && "detail" in body) { + const { detail } = body as { detail: unknown }; + if (typeof detail === "string") return detail; + if (Array.isArray(detail)) return detail.map((entry) => JSON.stringify(entry)).join("; "); + } + return `request failed with status ${status}`; +} + +type QueryValue = string | number | readonly string[] | undefined; + +async function getJson(path: string, query: Record = {}): Promise { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined) continue; + // An array becomes the same key repeated, which is how FastAPI's + // `list[str] | None = Query()` filters read a multi-value filter. + if (Array.isArray(value)) for (const entry of value) search.append(key, entry); + else search.set(key, String(value)); + } + const suffix = search.size > 0 ? `?${search}` : ""; + // Relative on purpose: the server serves this bundle and the API from the + // same origin, and Vite's dev proxy forwards /api to it. + const response = await fetch(`/api/v1${path}${suffix}`, { + headers: { accept: "application/json" }, + }); + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new ApiError(response.status, refusalDetail(body, response.status)); + } + return (await response.json()) as T; +} + +// Airflow states that mean "this run is finished". Anything else -- running, +// queued, a state a newer Airflow invented -- keeps the poll alive, so an +// unrecognized state errs toward refreshing rather than toward going stale. +const TERMINAL_RUN_STATES = new Set(["success", "failed", "skipped", "upstream_failed"]); + +export function isTerminalRunState(state: string | null | undefined): boolean { + return state !== null && state !== undefined && TERMINAL_RUN_STATES.has(state.toLowerCase()); +} + +const LIVE_POLL_MS = 4000; + +export function useWorkspaceConfig() { + return useQuery({ + queryKey: ["config"], + queryFn: () => getJson("/config"), + staleTime: Number.POSITIVE_INFINITY, + }); +} + +export function useRuntimeStatus() { + return useQuery({ + queryKey: ["runtime", "status"], + queryFn: () => getJson("/runtime/status"), + refetchInterval: LIVE_POLL_MS, + }); +} + +/** The master runs the canvas can be pointed at, newest first. */ +export function useRuntimeRuns(enabled: boolean) { + return useQuery({ + queryKey: ["runtime", "runs"], + queryFn: () => getJson("/runtime/runs", { limit: 25 }), + enabled, + refetchInterval: LIVE_POLL_MS, + }); +} + +/** The topology: what the DAGs and the pipeline's steps ARE, run or no run. */ +export function usePipelineGraph() { + return useQuery({ + queryKey: ["pipeline", "graph"], + queryFn: () => getJson("/pipeline/graph"), + // The bundle is re-rendered by `hflow up`, so the shape can change under a + // long-lived tab -- just far less often than a run's state does. + staleTime: 60_000, + }); +} + +/** One master run's live state over that topology. */ +export function useRunGraph(dagRunId: string | null) { + return useQuery({ + queryKey: ["runtime", "runs", dagRunId, "graph"], + queryFn: () => getJson(`/runtime/runs/${encodeURIComponent(dagRunId ?? "")}/graph`), + enabled: dagRunId !== null, + // Stop polling once the master run is finished: its stages are finished + // too, so there is nothing left to refresh. + refetchInterval: (query) => + isTerminalRunState(query.state.data?.master.state) ? false : LIVE_POLL_MS, + }); +} + +/** + * The episodes one ingest run produced, asked for by ALL of its stage run ids. + * + * This is the join the canvas drills through: every stage's `process_batch` + * stamps its own Airflow run id onto the catalog rows it appends + * (`episodes.orchestrator_run_id`). + * + * Every stage run id at once, not one: the catalog's `episodes` view is one row + * per episode -- the most recent append wins -- so in a full ingest the media + * stage's rows supersede sync's, meta's and labels'. Asking with a single + * stage's id therefore answers 0 for every stage but the last one to record, + * which was measured, not assumed. The union is the honest question: which + * episodes' current catalog row came out of this run. + */ +export function useRunEpisodes(orchestratorRunIds: readonly string[], limit = 200) { + // Sorted so the cache key does not depend on the order the stages arrived in. + const runIds = [...orchestratorRunIds].sort(); + return useQuery({ + queryKey: ["episodes", "byRun", runIds, limit], + queryFn: () => getJson("/episodes", { orchestrator_run_id: runIds, limit }), + enabled: runIds.length > 0, + }); +} + +export function useEpisodeDossier(episodeId: string | null) { + return useQuery({ + queryKey: ["episodes", episodeId], + queryFn: () => getJson(`/episodes/${encodeURIComponent(episodeId ?? "")}`), + enabled: episodeId !== null, + }); +} diff --git a/ui/src/apiSchema.ts b/ui/src/apiSchema.ts new file mode 100644 index 0000000..c8490ab --- /dev/null +++ b/ui/src/apiSchema.ts @@ -0,0 +1,2245 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/v1/catalog/tables": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Catalog Tables */ + get: operations["list_catalog_tables_api_v1_catalog_tables_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/catalog/tables/{table_name}/summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Catalog Table Summary */ + get: operations["read_catalog_table_summary_api_v1_catalog_tables__table_name__summary_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Config */ + get: operations["read_config_api_v1_config_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/curation/pin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Pin Manifest */ + post: operations["pin_manifest_api_v1_curation_pin_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/curation/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Run Curation Preview */ + post: operations["run_curation_preview_api_v1_curation_preview_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/curation/report": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Run Curation Report */ + post: operations["run_curation_report_api_v1_curation_report_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/episodes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Episodes */ + get: operations["list_episodes_api_v1_episodes_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/episodes/facets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Episode Facets */ + get: operations["read_episode_facets_api_v1_episodes_facets_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/episodes/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Episode Stats */ + get: operations["read_episode_stats_api_v1_episodes_stats_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/episodes/{episode_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Episode */ + get: operations["read_episode_api_v1_episodes__episode_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/episodes/{episode_id}/canonical": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Episode Canonical */ + get: operations["read_episode_canonical_api_v1_episodes__episode_id__canonical_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/episodes/{episode_id}/media/{artifact_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Episode Media */ + get: operations["read_episode_media_api_v1_episodes__episode_id__media__artifact_name__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/episodes/{episode_id}/timeline": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Episode Timeline */ + get: operations["read_episode_timeline_api_v1_episodes__episode_id__timeline_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Health */ + get: operations["read_health_api_v1_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/manifests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Manifests */ + get: operations["list_manifests_api_v1_manifests_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/manifests/{manifest_id}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download Manifest */ + get: operations["download_manifest_api_v1_manifests__manifest_id__download_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/pipeline": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Pipeline */ + get: operations["read_pipeline_api_v1_pipeline_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/pipeline/graph": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read Pipeline Graph + * @description The merged picture: the DAG topology plus the pipeline's user steps. + * + * Three degraded states, each explicit rather than an error: no runtime + * addressed (``dag_ids_known: false``, display-only ids), no + * ``--pipeline`` (``steps_known: false``, no user steps and no + * quarantine gate), and both at once -- the common first-run case. + */ + get: operations["read_pipeline_graph_api_v1_pipeline_graph_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/queries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Saved Queries */ + get: operations["list_saved_queries_api_v1_queries_get"]; + put?: never; + /** Create Saved Query */ + post: operations["create_saved_query_api_v1_queries_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/queries/{query_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update Saved Query */ + put: operations["update_saved_query_api_v1_queries__query_id__put"]; + post?: never; + /** Delete Saved Query */ + delete: operations["delete_saved_query_api_v1_queries__query_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/runtime/ingest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Trigger Ingest */ + post: operations["trigger_ingest_api_v1_runtime_ingest_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/runtime/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Runtime Runs */ + get: operations["list_runtime_runs_api_v1_runtime_runs_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/runtime/runs/{dag_run_id}/graph": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read Run Graph + * @description One master run's live state over the same topology. + * + * The master run is addressed directly; each stage's sub-DAG run is + * resolved by the documented heuristic in :func:`_matched_stage_run`. + */ + get: operations["read_run_graph_api_v1_runtime_runs__dag_run_id__graph_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/runtime/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read Runtime Status */ + get: operations["read_runtime_status_api_v1_runtime_status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * CatalogTableDescription + * @description One browsable catalog relation and its live columns. + */ + CatalogTableDescription: { + /** Columns */ + columns: components["schemas"]["ColumnDescriptor"][]; + /** + * Kind + * @enum {string} + */ + kind: "view" | "table"; + /** Name */ + name: string; + }; + /** + * CatalogTableSummaryResponse + * @description One relation's row count and DuckDB's own column profile. + */ + CatalogTableSummaryResponse: { + /** + * Columns + * @description DuckDB SUMMARIZE rows; see CurationPreviewResponse.column_stats. + */ + columns: { + [key: string]: unknown; + }[]; + /** Row Count */ + row_count: number; + }; + /** CatalogTablesResponse */ + CatalogTablesResponse: { + /** Tables */ + tables: components["schemas"]["CatalogTableDescription"][]; + }; + /** + * CategoricalColumnStats + * @description A low-cardinality column's top values under the current filters. + */ + CategoricalColumnStats: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "categorical"; + /** Name */ + name: string; + /** + * Other Count + * @description Non-null rows beyond the served top values. + */ + other_count: number; + /** Values */ + values: components["schemas"]["ValueCount"][]; + }; + /** + * CheckCoverageEntry + * @description One check's coverage denominator over the WHOLE catalog, not the cut. + * + * Also the sidecar's stored shape, nested inside every stored manifest + * entry's ``coverage`` (see the module note). + */ + CheckCoverageEntry: { + /** Check Name */ + check_name: string; + /** Episodes Ran */ + episodes_ran: number; + /** Fraction */ + fraction: number; + /** Total Episodes */ + total_episodes: number; + }; + /** + * ColumnDescriptor + * @description One result column as DuckDB's ``DESCRIBE`` reports it. + */ + ColumnDescriptor: { + /** Name */ + name: string; + /** Type */ + type: string; + }; + /** + * CurationPreviewResponse + * @description A user SELECT's first rows, its full count, and optional column stats. + */ + CurationPreviewResponse: { + /** + * Column Stats + * @description DuckDB SUMMARIZE rows (column_name, column_type, min, max, null_percentage, ...). DuckDB owns that shape and varies it by version, so it is served as-is. Null unless the request asked for stats. + */ + column_stats: + | { + [key: string]: unknown; + }[] + | null; + /** Columns */ + columns: components["schemas"]["ColumnDescriptor"][]; + /** + * Row Count + * @description Rows the SELECT returns in full, independent of limit. + */ + row_count: number; + /** + * Rows + * @description Rows of the user's own SELECT; its columns are described by 'columns'. + */ + rows: { + [key: string]: unknown; + }[]; + /** + * Sql + * @description The logical wrapped SELECT, copy-pastable as-is. The executed statement adds a '* REPLACE (...)' projection rendering TIMESTAMPTZ columns as UTC ISO text (the locked connection cannot SET TimeZone), which is a rendering detail of these rows rather than part of the query a user wrote. + */ + sql: string; + /** Truncated */ + truncated: boolean; + }; + /** + * CurationReportResponse + * @description What a cut would contain, and what evidence backs it -- no files written. + */ + CurationReportResponse: { + /** Coverage */ + coverage: components["schemas"]["CheckCoverageEntry"][]; + /** Row Count */ + row_count: number; + /** Total Episodes */ + total_episodes: number; + }; + /** + * DagTaskNodePayload + * @description One task of a generated DAG (mirrors ``hflow.runtime.DagTaskNode``). + */ + DagTaskNodePayload: { + /** + * Deferred + * @description Defers instead of holding a worker slot. + */ + deferred: boolean; + /** + * Mapped + * @description Dynamically mapped: one instance per planned batch. + */ + mapped: boolean; + /** Summary */ + summary: string; + /** Task Id */ + task_id: string; + }; + /** + * DagTopologyPayload + * @description One DAG's real shape: its tasks and their real dependency edges. + */ + DagTopologyPayload: { + /** Dag Id */ + dag_id: string; + /** + * Edges + * @description [upstream, downstream] task-id pairs, in declaration order. + */ + edges: [string, string][]; + /** Tasks */ + tasks: components["schemas"]["DagTaskNodePayload"][]; + }; + /** + * DossierEpisode + * @description The episode's own ``episodes_latest`` row plus the two derived fields. + * + * ``extra="allow"``: every column of that row rides along unchanged, because + * the catalog's columns are data this module cannot enumerate. + */ + DossierEpisode: { + /** + * Quarantine Tags + * @description Parsed out of the row's quarantine_tags_json; empty when not quarantined. + */ + quarantine_tags: string[]; + /** + * Status + * @enum {string} + */ + status: "ok" | "quarantined"; + } & { + [key: string]: unknown; + }; + /** + * EpisodeCheckRunRecord + * @description One recorded check invocation. + */ + EpisodeCheckRunRecord: { + /** Check Name */ + check_name: string | null; + /** Check Version */ + check_version: string | null; + /** Critical */ + critical: boolean | null; + /** Duration S */ + duration_s: number | null; + /** Error */ + error: string | null; + /** Recorded At */ + recorded_at: string | null; + /** Run Fingerprint */ + run_fingerprint: string | null; + /** Status */ + status: string | null; + }; + /** + * EpisodeDossierResponse + * @description Everything the episode page shows for one episode. + */ + EpisodeDossierResponse: { + /** Canonical Url */ + canonical_url: string | null; + /** Check Runs */ + check_runs: components["schemas"]["EpisodeCheckRunRecord"][]; + episode: components["schemas"]["DossierEpisode"]; + /** + * History + * @description Every append of this episode, newest first: raw episodes_raw rows, whose columns are the catalog's (see EpisodePageResponse.rows). + */ + history: { + [key: string]: unknown; + }[]; + /** Intervals */ + intervals: components["schemas"]["EpisodeIntervalRecord"][]; + /** Measurements */ + measurements: components["schemas"]["EpisodeMeasurementRecord"][]; + /** Media */ + media: components["schemas"]["EpisodeMediaArtifact"][]; + /** Tags */ + tags: components["schemas"]["EpisodeTagRecord"][]; + }; + /** + * EpisodeFacetsResponse + * @description Facet value counts over the wide episodes view; NULL buckets skipped. + * + * This model is the one owner of WHICH columns are faceted: ``_catalog`` + * reads the column list off these fields rather than restating it. + */ + EpisodeFacetsResponse: { + /** Embodiment */ + embodiment: components["schemas"]["ValueCount"][]; + /** Operator */ + operator: components["schemas"]["ValueCount"][]; + /** Pipeline Version */ + pipeline_version: components["schemas"]["ValueCount"][]; + /** Status */ + status: components["schemas"]["ValueCount"][]; + /** Task */ + task: components["schemas"]["ValueCount"][]; + }; + /** + * EpisodeIntervalRecord + * @description One interval of the episode's LATEST run. + * + * ``check_version`` rides in from that run's ``check_runs`` row (a LEFT + * JOIN -- the intervals table carries no version of its own). + */ + EpisodeIntervalRecord: { + /** Check Name */ + check_name: string | null; + /** Check Version */ + check_version: string | null; + /** End Ns */ + end_ns: number | null; + /** Label */ + label: string | null; + /** Start Ns */ + start_ns: number | null; + }; + /** + * EpisodeMeasurementRecord + * @description One measurement, latest per key. + */ + EpisodeMeasurementRecord: { + /** Check Name */ + check_name: string | null; + /** Check Version */ + check_version: string | null; + /** Key */ + key: string | null; + /** Recorded At */ + recorded_at: string | null; + /** Value Bool */ + value_bool: boolean | null; + /** Value Double */ + value_double: number | null; + /** Value Text */ + value_text: string | null; + }; + /** + * EpisodeMediaArtifact + * @description One cataloged media artifact and, when servable, its byte URL. + */ + EpisodeMediaArtifact: { + /** Name */ + name: string; + /** Uri */ + uri: string; + /** + * Url + * @description Same-origin byte-serving path, or null when the cataloged file is missing or lands outside the workspace data root. + */ + url: string | null; + }; + /** + * EpisodePageResponse + * @description One filtered, ordered page of the wide ``episodes`` view. + */ + EpisodePageResponse: { + /** Columns */ + columns: components["schemas"]["ColumnDescriptor"][]; + /** + * Rows + * @description Rows of the wide episodes view. Its columns are data (one per measurement key present at open time), so they are described by 'columns' rather than enumerated here. + */ + rows: { + [key: string]: unknown; + }[]; + /** + * Sql + * @description The SELECT compiled for exactly these filters, with values inlined so it is copy-pastable and runs against the same catalog. + */ + sql: string; + /** + * Total + * @description Rows matching the SAME filters, ignoring limit/offset. + */ + total: number; + }; + /** + * EpisodeStatsResponse + * @description Per-column mini-distributions; degenerate columns are omitted entirely. + */ + EpisodeStatsResponse: { + /** Columns */ + columns: ( + | components["schemas"]["NumericColumnStats"] + | components["schemas"]["CategoricalColumnStats"] + )[]; + }; + /** + * EpisodeTagRecord + * @description One tag of the episode's LATEST run. + */ + EpisodeTagRecord: { + /** Check Name */ + check_name: string | null; + /** Recorded At */ + recorded_at: string | null; + /** Tag */ + tag: string | null; + }; + /** + * EpisodeTimelineResponse + * @description One episode's time axis. All-null bounds mean the span is unknown -- + * a client must say so rather than draw a fabricated axis. + */ + EpisodeTimelineResponse: { + /** Duration S */ + duration_s: number | null; + /** End Ns */ + end_ns: number | null; + /** Intervals */ + intervals: components["schemas"]["TimelineInterval"][]; + /** Measurements */ + measurements: components["schemas"]["TimelineMeasurement"][]; + /** Start Ns */ + start_ns: number | null; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * HealthResponse + * @description The liveness answer: the cheapest endpoint a probe can poll. + */ + HealthResponse: { + /** Ok */ + ok: boolean; + }; + /** IngestRequest */ + IngestRequest: { + /** Batch Count */ + batch_count?: number | null; + /** + * Mode + * @default batch + */ + mode: string; + /** + * Profile + * @default full + */ + profile: string; + /** Uris */ + uris: string[]; + }; + /** + * IngestTriggerResponse + * @description What Airflow answered when the run was triggered. + */ + IngestTriggerResponse: { + /** Dag Run Id */ + dag_run_id: string | null; + /** State */ + state: string | null; + }; + /** + * MappedFanOutSummary + * @description The fan-out's live split, counted server-side over EVERY mapped instance. + * + * Complete on its own: ``by_state`` partitions all ``total`` instances of + * ``task_id`` (an instance Airflow has not scheduled yet counts under + * ``no_status``), so ``total == sum(by_state.values())`` always holds and a + * client never has to recount the raw instances to size or colour the + * fan-out. Only a replay at some earlier instant is a different fact, and + * that one the server cannot answer. + */ + MappedFanOutSummary: { + /** By State */ + by_state: { + [key: string]: number; + }; + /** Task Id */ + task_id: string; + /** + * Total + * @description Instances reported for the mapped task. Before the fan-out expands Airflow reports one unexpanded instance, which is counted -- that is the truth at that moment. + */ + total: number; + }; + /** + * NumericColumnStats + * @description A numeric column's mini-distribution under the current filters. + */ + NumericColumnStats: { + /** Buckets */ + buckets: components["schemas"]["NumericHistogramBucket"][]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "numeric"; + /** Name */ + name: string; + }; + /** + * NumericHistogramBucket + * @description One histogram bucket: ``lo`` inclusive, ``hi`` inclusive on the last. + */ + NumericHistogramBucket: { + /** Count */ + count: number; + /** Hi */ + hi: number; + /** Lo */ + lo: number; + }; + /** + * ObservedCheckVersion + * @description What the catalog has SEEN of one (check, version) pair. + */ + ObservedCheckVersion: { + /** Check Name */ + check_name: string | null; + /** Check Version */ + check_version: string | null; + /** First Seen */ + first_seen: string | null; + /** Last Seen */ + last_seen: string | null; + /** Run Count */ + run_count: number; + }; + /** PinRequest */ + PinRequest: { + /** + * Description + * @default + */ + description: string; + /** Name */ + name: string; + /** Sql */ + sql: string; + }; + /** + * PinnedManifestEntry + * @description One registry entry for an immutable pinned manifest file. + * + * Also the sidecar's stored shape for a manifest (see the module note). + */ + PinnedManifestEntry: { + /** + * Coverage + * @description Frozen at pin time. + */ + coverage: components["schemas"]["CheckCoverageEntry"][]; + /** + * Created At + * @description ISO-8601 UTC. + */ + created_at: string; + /** Description */ + description: string; + /** Id */ + id: string; + /** + * Manifest Path + * @description Data-root-relative, e.g. 'manifests/-.parquet'. + */ + manifest_path: string; + /** Name */ + name: string; + /** Row Count */ + row_count: number; + /** Sql */ + sql: string; + /** Total Episodes */ + total_episodes: number; + }; + /** PinnedManifestListResponse */ + PinnedManifestListResponse: { + /** Manifests */ + manifests: components["schemas"]["PinnedManifestEntry"][]; + }; + /** + * PipelineEngineStep + * @description Engine work inside one stage that no manifest lists. + */ + PipelineEngineStep: { + /** Name */ + name: string; + /** Summary */ + summary: string; + }; + /** + * PipelineGate + * @description A step's declarative accept policy: every threshold must hold. + */ + PipelineGate: { + /** Accept When */ + accept_when: components["schemas"]["PipelineGateThreshold"][]; + }; + /** + * PipelineGateThreshold + * @description One accept condition of a step's gate. + */ + PipelineGateThreshold: { + /** + * Across + * @description How several matching keys fold: 'every_key' or 'any_key'. + */ + across: string; + /** + * Comparison + * @description 'at_most' or 'at_least', both inclusive. + */ + comparison: string; + /** + * Key Pattern + * @description Glob over the measurement keys this compares. + */ + key_pattern: string; + /** Value */ + value: number; + }; + /** + * PipelineGraphResponse + * @description The ingest DAG's shape merged with the pipeline's own steps. + */ + PipelineGraphResponse: { + /** + * Dag Ids Known + * @description False when no runtime is addressed: the dag ids are display-only. + */ + dag_ids_known: boolean; + master: components["schemas"]["DagTopologyPayload"]; + /** @description Null exactly when steps_known is false. */ + quarantine_gate: components["schemas"]["QuarantineGate"] | null; + /** Stages */ + stages: components["schemas"]["PipelineGraphStage"][]; + /** + * Steps Known + * @description False without --pipeline: what runs inside process_batch is unknown. + */ + steps_known: boolean; + }; + /** + * PipelineGraphStage + * @description One stage lane of the pipeline graph: its DAG plus what runs inside it. + */ + PipelineGraphStage: { + dag: components["schemas"]["DagTopologyPayload"]; + /** Description */ + description: string; + /** Enabling Profiles */ + enabling_profiles: string[]; + /** Engine Steps */ + engine_steps: components["schemas"]["PipelineEngineStep"][]; + /** Gate Task Id */ + gate_task_id: string; + stage: components["schemas"]["Stage"]; + /** Title */ + title: string; + /** Trigger Task Id */ + trigger_task_id: string; + /** User Steps */ + user_steps: components["schemas"]["PipelineUserStep"][]; + }; + /** + * PipelineResponse + * @description The startup-imported App, described over this workspace's catalog. + */ + PipelineResponse: { + /** + * Manifest + * @description The pipeline manifest exactly as hflow.manifest.PipelineManifest renders it. hflow.manifest owns that shape and stamps it with 'manifest_version', so it is forwarded rather than mirrored here. + */ + manifest: { + [key: string]: unknown; + }; + /** Observed */ + observed: components["schemas"]["ObservedCheckVersion"][]; + /** @description Null when staleness is unknowable (no catalog yet). */ + stale: components["schemas"]["StaleSummary"] | null; + }; + /** + * PipelineUserStep + * @description A registered step as the graph endpoint serves it. + * + * ``tier`` mirrors ``hflow.App._ordered_checks``: tier 2 is exactly the steps + * declaring ``requires`` or ``uses``. Steps within a tier have NO ordering. + */ + PipelineUserStep: { + /** Critical */ + critical: boolean; + /** @description The policy this step rejects on, when it declares one. `critical` says a gate exists; this says which threshold on which measurement it is. */ + gate?: components["schemas"]["PipelineGate"] | null; + kind: components["schemas"]["StepKind"]; + /** Name */ + name: string; + /** Requires */ + requires: string[]; + /** + * Tier + * @enum {integer} + */ + tier: 1 | 2; + /** Uses */ + uses: string | null; + /** + * Version + * @description Content hash of the live function. + */ + version: string; + }; + /** PreviewRequest */ + PreviewRequest: { + /** + * Limit + * @default 100 + */ + limit: number; + /** Sql */ + sql: string; + /** + * Stats + * @default false + */ + stats: boolean; + }; + /** + * QuarantineGate + * @description The one real cross-step edge, served as its own object rather than as + * an edge in either graph. + */ + QuarantineGate: { + /** Critical Step Names */ + critical_step_names: string[]; + /** Explanation */ + explanation: string; + from_stage: components["schemas"]["Stage"]; + /** To Stages */ + to_stages: components["schemas"]["Stage"][]; + }; + /** ReportRequest */ + ReportRequest: { + /** Sql */ + sql: string; + }; + /** + * RunGraphMaster + * @description The master run's own live state. + */ + RunGraphMaster: { + /** Dag Run Id */ + dag_run_id: string; + /** State */ + state: string | null; + /** Tasks */ + tasks: components["schemas"]["RunTaskInstance"][]; + }; + /** + * RunGraphResponse + * @description One master run's live state over the ingest topology. + */ + RunGraphResponse: { + master: components["schemas"]["RunGraphMaster"]; + /** Stages */ + stages: components["schemas"]["RunGraphStage"][]; + }; + /** + * RunGraphStage + * @description One stage's live state for this master run, or explicit nulls when the + * stage never ran for it. + */ + RunGraphStage: { + /** Dag Id */ + dag_id: string; + /** Dag Run Id */ + dag_run_id: string | null; + mapped_summary: components["schemas"]["MappedFanOutSummary"] | null; + /** + * Match + * @description How this stage run was attributed to the master run. Airflow stores no parent-run link, so the only honest answer is 'heuristic' -- the earliest stage run started inside this master run's own window -- or null (nothing matched). Two master runs OVERLAPPING in time can still be attributed the same stage run. + */ + match: "heuristic" | null; + stage: components["schemas"]["Stage"]; + /** State */ + state: string | null; + /** Tasks */ + tasks: components["schemas"]["RunTaskInstance"][]; + }; + /** + * RunTaskInstance + * @description One Airflow task instance, reduced to what the graph draws. + */ + RunTaskInstance: { + /** Duration S */ + duration_s: number | null; + /** End Date */ + end_date: string | null; + /** + * Map Index + * @description -1 means the task is not mapped. + */ + map_index: number; + /** + * Queued At + * @description When the scheduler queued the task, so a replay can tell 'waiting for a worker' from 'running'. Airflow may omit it. + */ + queued_at: string | null; + /** Start Date */ + start_date: string | null; + /** State */ + state: string | null; + /** Task Id */ + task_id: string | null; + /** Try Number */ + try_number: number | null; + }; + /** + * RuntimeHealthComponents + * @description Airflow's per-component health. + * + * This model is the one owner of WHICH components /runtime/status reports: + * ``_runtime`` reads the names off these fields. A component absent from the + * deployment (a minimal stack runs no triggerer) reports null. + */ + RuntimeHealthComponents: { + /** Dag Processor */ + dag_processor: string | null; + /** Metadatabase */ + metadatabase: string | null; + /** Scheduler */ + scheduler: string | null; + /** Triggerer */ + triggerer: string | null; + }; + /** + * RuntimeRunSummary + * @description One master DAG run, reduced to what the Runs page shows. + */ + RuntimeRunSummary: { + /** + * Conf + * @description The trigger's own input, forwarded verbatim. + */ + conf: { + [key: string]: unknown; + }; + /** Dag Run Id */ + dag_run_id: string | null; + /** End Date */ + end_date: string | null; + /** Logical Date */ + logical_date: string | null; + /** Start Date */ + start_date: string | null; + /** State */ + state: string | null; + }; + /** RuntimeRunsResponse */ + RuntimeRunsResponse: { + /** Runs */ + runs: components["schemas"]["RuntimeRunSummary"][]; + /** + * Stages + * @description Per-stage recent runs; null for a remote runtime, whose stage sub-DAG ids only a bundle manifest records. + */ + stages: components["schemas"]["StageRecentRuns"][] | null; + }; + /** + * RuntimeStatusResponse + * @description Whether this workspace's ingest runtime is addressed AND answering. + * + * Every field except ``available`` defaults to "not known", so an + * unavailable answer states only the facts it actually has -- there is no + * second hand-written shape for the unavailable case to drift from. + */ + RuntimeStatusResponse: { + /** + * Airflow Web Url + * @description Deep-link base for the Airflow web UI, AS ADDRESSED FROM THE WORKSPACE HOST. Only a local bundle records its own address; a remote endpoint's is unknown, never guessed. + */ + airflow_web_url?: string | null; + /** + * Airflow Web Url Host Only + * @description True when airflow_web_url is a loopback address, so it resolves only on the workspace host: a browser on another machine cannot follow it, and the runtime is reachable there only through a tunnel or a wider `hflow up --api-bind-host`. + * @default false + */ + airflow_web_url_host_only: boolean; + /** Available */ + available: boolean; + /** Dag Id */ + dag_id?: string | null; + /** + * Detail + * @description Why the runtime is unavailable; null when it is available. + */ + detail?: string | null; + health?: components["schemas"]["RuntimeHealthComponents"] | null; + /** + * Registered + * @description Whether the master DAG is registered. Null means unknown (an auth or transient failure), which is not the same as false. + */ + registered?: boolean | null; + /** Source */ + source?: ("bundle" | "remote") | null; + }; + /** SavedQueryCreateRequest */ + SavedQueryCreateRequest: { + /** Name */ + name: string; + /** Sql */ + sql: string; + }; + /** + * SavedQueryEntry + * @description One saved studio query. + * + * Also the sidecar's stored shape for a saved query (see the module note). + */ + SavedQueryEntry: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Sql */ + sql: string; + /** + * Updated At + * @description ISO-8601 UTC. + */ + updated_at: string; + }; + /** SavedQueryListResponse */ + SavedQueryListResponse: { + /** Queries */ + queries: components["schemas"]["SavedQueryEntry"][]; + }; + /** SavedQueryUpdateRequest */ + SavedQueryUpdateRequest: { + /** Name */ + name?: string | null; + /** Sql */ + sql?: string | null; + }; + /** + * Stage + * @description The ingest stage graph's toggleable sub-DAGs, as stage names shared with the DAGs. + * + * These strings are conf vocabulary: the master DAG resolves a run profile + * to a stage set and triggers only the sub-DAGs it names, and + * ``App.process(stages=...)`` runs the same set in-process. One owner -- + * here -- so the runner and the DAG bundle can never disagree. + * @enum {string} + */ + Stage: "sync" | "meta" | "labels" | "media"; + /** + * StageRecentRuns + * @description One stage's most recent runs. NOT correlated with any master run. + */ + StageRecentRuns: { + /** Dag Id */ + dag_id: string; + /** Recent */ + recent: components["schemas"]["StageRunSummary"][]; + stage: components["schemas"]["Stage"]; + }; + /** + * StageRunSummary + * @description One stage sub-DAG run in a stage's recent strip. + */ + StageRunSummary: { + /** Dag Run Id */ + dag_run_id: string | null; + /** End Date */ + end_date: string | null; + /** Start Date */ + start_date: string | null; + /** State */ + state: string | null; + }; + /** + * StaleSummary + * @description How many recorded episodes are stale against the App's current versions. + */ + StaleSummary: { + /** Count */ + count: number; + /** Pipeline Version */ + pipeline_version: string; + }; + /** + * StepKind + * @description Which registration surface a step came from. + * @enum {string} + */ + StepKind: "check" | "enrichment"; + /** + * TimelineInterval + * @description One interval placed on the episode's axis, in absolute ns and in + * seconds RELATIVE to the span start (both computed server-side). + */ + TimelineInterval: { + /** Check Name */ + check_name: string | null; + /** End Ns */ + end_ns: number | null; + /** End S */ + end_s: number | null; + /** + * Kind + * @description Colour group: the label's ':' prefix, else the whole label, else the check that produced it. + */ + kind: string; + /** Label */ + label: string | null; + /** Start Ns */ + start_ns: number | null; + /** Start S */ + start_s: number | null; + }; + /** + * TimelineMeasurement + * @description One numeric measurement, ready to draw as a bar. + */ + TimelineMeasurement: { + /** Key */ + key: string; + /** + * Unit + * @description Inferred from the key's unit suffix; null when no dimension is known. + */ + unit: string | null; + /** Value */ + value: number; + }; + /** ValidationError */ + ValidationError: { + /** Context */ + ctx?: Record; + /** Input */ + input?: unknown; + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + /** + * ValueCount + * @description One value and how many episodes carry it. + */ + ValueCount: { + /** Count */ + count: number; + /** Value */ + value: string; + }; + /** + * WorkspaceCapabilities + * @description What this launch can actually do over this data root. + * + * ``runtime`` means ADDRESSED (a rendered bundle or an exported remote URL), + * not reachable -- /runtime/status owns liveness. + */ + WorkspaceCapabilities: { + /** Catalog */ + catalog: boolean; + /** + * Curation + * @description Whether the curation studio's durable state can be written at all: saved queries, the pinned-manifest registry, and the manifest files need a LOCAL data root, so a bucket-backed workspace answers 501 for every one of them and the frontend should not offer them. + */ + curation: boolean; + /** Media */ + media: boolean; + /** Pipeline */ + pipeline: boolean; + /** Runtime */ + runtime: boolean; + }; + /** + * WorkspaceConfigResponse + * @description What this server is serving, and what the frontend may offer. + * + * Deliberately carries no Airflow deep-link base: /runtime/status is the one + * owner of the runtime's addressing facts, including its web URL. + */ + WorkspaceConfigResponse: { + capabilities: components["schemas"]["WorkspaceCapabilities"]; + /** Data Root */ + data_root: string; + /** Hflow Server Version */ + hflow_server_version: string; + /** Hflow Version */ + hflow_version: string; + /** + * Ingest Modes + * @description Live ingest modes from hflow.steps.IngestMode; same contract as run_profiles. + */ + ingest_modes: string[]; + /** + * Mode + * @constant + */ + mode: "local"; + /** Read Only */ + read_only: boolean; + /** + * Run Profiles + * @description Live run-profile names from hflow.steps.RUN_PROFILES, served so the frontend never hardcodes them. + */ + run_profiles: string[]; + /** Workspace Id */ + workspace_id: string | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + list_catalog_tables_api_v1_catalog_tables_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CatalogTablesResponse"]; + }; + }; + }; + }; + read_catalog_table_summary_api_v1_catalog_tables__table_name__summary_get: { + parameters: { + query?: never; + header?: never; + path: { + table_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CatalogTableSummaryResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_config_api_v1_config_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkspaceConfigResponse"]; + }; + }; + }; + }; + pin_manifest_api_v1_curation_pin_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PinRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PinnedManifestEntry"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + run_curation_preview_api_v1_curation_preview_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PreviewRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CurationPreviewResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + run_curation_report_api_v1_curation_report_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReportRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CurationReportResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_episodes_api_v1_episodes_get: { + parameters: { + query?: { + order_by?: string; + order?: "asc" | "desc"; + limit?: number; + offset?: number; + task?: string[] | null; + operator?: string[] | null; + embodiment?: string[] | null; + orchestrator_run_id?: string[] | null; + status?: ("ok" | "quarantined") | null; + success?: ("true" | "false") | null; + search?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EpisodePageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_episode_facets_api_v1_episodes_facets_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EpisodeFacetsResponse"]; + }; + }; + }; + }; + read_episode_stats_api_v1_episodes_stats_get: { + parameters: { + query?: { + task?: string[] | null; + operator?: string[] | null; + embodiment?: string[] | null; + orchestrator_run_id?: string[] | null; + status?: ("ok" | "quarantined") | null; + success?: ("true" | "false") | null; + search?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EpisodeStatsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_episode_api_v1_episodes__episode_id__get: { + parameters: { + query?: never; + header?: never; + path: { + episode_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EpisodeDossierResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_episode_canonical_api_v1_episodes__episode_id__canonical_get: { + parameters: { + query?: never; + header?: never; + path: { + episode_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The file's bytes. An allowlisted inert media type (image, audio, video) is served inline under its own content type; anything else -- and every download -- is opaque application/octet-stream. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_episode_media_api_v1_episodes__episode_id__media__artifact_name__get: { + parameters: { + query?: never; + header?: never; + path: { + episode_id: string; + artifact_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The file's bytes. An allowlisted inert media type (image, audio, video) is served inline under its own content type; anything else -- and every download -- is opaque application/octet-stream. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_episode_timeline_api_v1_episodes__episode_id__timeline_get: { + parameters: { + query?: never; + header?: never; + path: { + episode_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EpisodeTimelineResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_health_api_v1_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + }; + }; + list_manifests_api_v1_manifests_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PinnedManifestListResponse"]; + }; + }; + }; + }; + download_manifest_api_v1_manifests__manifest_id__download_get: { + parameters: { + query?: never; + header?: never; + path: { + manifest_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The file's bytes. An allowlisted inert media type (image, audio, video) is served inline under its own content type; anything else -- and every download -- is opaque application/octet-stream. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_pipeline_api_v1_pipeline_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PipelineResponse"]; + }; + }; + }; + }; + read_pipeline_graph_api_v1_pipeline_graph_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PipelineGraphResponse"]; + }; + }; + }; + }; + list_saved_queries_api_v1_queries_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedQueryListResponse"]; + }; + }; + }; + }; + create_saved_query_api_v1_queries_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SavedQueryCreateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedQueryEntry"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_saved_query_api_v1_queries__query_id__put: { + parameters: { + query?: never; + header?: never; + path: { + query_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SavedQueryUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedQueryEntry"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_saved_query_api_v1_queries__query_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + query_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + trigger_ingest_api_v1_runtime_ingest_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IngestRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IngestTriggerResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_runtime_runs_api_v1_runtime_runs_get: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RuntimeRunsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_run_graph_api_v1_runtime_runs__dag_run_id__graph_get: { + parameters: { + query?: never; + header?: never; + path: { + dag_run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RunGraphResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + read_runtime_status_api_v1_runtime_status_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RuntimeStatusResponse"]; + }; + }; + }; + }; +} diff --git a/ui/src/canvas/CanvasNodeView.tsx b/ui/src/canvas/CanvasNodeView.tsx new file mode 100644 index 0000000..7abce98 --- /dev/null +++ b/ui/src/canvas/CanvasNodeView.tsx @@ -0,0 +1,47 @@ +// The one node renderer. Every level draws the same box, differing only in its +// tone, its shape and whether it offers a drill-down, so there is nothing per +// level to keep consistent. + +import { Handle, type Node, type NodeProps, Position } from "@xyflow/react"; +import type { CanvasNodeData } from "./buildGraph"; + +export type FlowNode = Node; + +export function CanvasNodeView({ data, selected }: NodeProps) { + const drillable = data.drillTo !== null; + return ( +
+ {/* Left in, right out: the layout is left-to-right, so the handles have + to match or every edge would leave from the wrong side. */} + +
+ {data.title} + {/* Decorative, so hidden from assistive tech: what it announces is + already on the inspector's own "Open" button, and a bare chevron + read aloud on every second node would be noise. */} + {drillable ? ( + + ) : null} +
+ {data.subtitle === null ? null :
{data.subtitle}
} + {data.badges.length === 0 ? null : ( +
+ {data.badges.map((badge) => ( + + {badge} + + ))} +
+ )} + +
+ ); +} + +export const CANVAS_NODE_TYPES = { canvas: CanvasNodeView }; diff --git a/ui/src/canvas/buildGraph.test.ts b/ui/src/canvas/buildGraph.test.ts new file mode 100644 index 0000000..c26a2f1 --- /dev/null +++ b/ui/src/canvas/buildGraph.test.ts @@ -0,0 +1,670 @@ +// buildGraph is where this UI's real decisions live, and it is pure, so it can +// be pinned without a browser. These tests are about the DECISIONS -- which +// edges are honest, what a missing run means, where a drill-down goes -- not +// about node counts for their own sake. + +import { describe, expect, it } from "vitest"; +import type { + EpisodeDossier, + EpisodePage, + PipelineGraph, + PipelineUserStep, + RunGraph, + RunTaskInstance, + Stage, +} from "../api"; +import { buildGraph, type CanvasGraph, stageRunIds } from "./buildGraph"; +import type { CanvasFocus } from "./focus"; + +function dagTask(taskId: string, overrides: { mapped?: boolean; deferred?: boolean } = {}) { + return { + task_id: taskId, + summary: `${taskId} summary`, + mapped: overrides.mapped ?? false, + deferred: overrides.deferred ?? false, + }; +} + +function userStep(name: string, overrides: Partial = {}): PipelineUserStep { + return { + name, + kind: "check", + version: `v-${name}`, + critical: false, + requires: [], + uses: null, + gate: null, + tier: 1, + ...overrides, + }; +} + +const STAGES: Stage[] = ["sync", "meta", "labels", "media"]; + +// The master's real task ids, spelled exactly as hflow.runtime mints them. +const RESOLVE_TASK_ID = "resolve_profile"; + +/** + * The generated topology's real shape, small enough to read in one screen. + * + * The master's edges are all three families the renderer declares, because two + * of them overlap and the overlap is what the run level has to resolve: the + * profile task feeds EVERY stage gate, each gate feeds its own trigger, and + * each trigger feeds the NEXT stage's gate. Leaving the third family out would + * make a straight-line assertion pass for the wrong reason. + */ +function pipelineGraph(overrides: Partial = {}): PipelineGraph { + return { + dag_ids_known: true, + steps_known: true, + master: { + dag_id: "kitchen_ingest", + tasks: [ + dagTask(RESOLVE_TASK_ID), + ...STAGES.flatMap((stage) => [ + dagTask(`enabled_${stage}`), + dagTask(`trigger_${stage}`, { deferred: true }), + ]), + ], + edges: [ + ...STAGES.map((stage) => [RESOLVE_TASK_ID, `enabled_${stage}`] as [string, string]), + ...STAGES.map((stage) => [`enabled_${stage}`, `trigger_${stage}`] as [string, string]), + ...STAGES.slice(0, -1).map( + (stage, index) => + [`trigger_${stage}`, `enabled_${STAGES[index + 1]}`] as [string, string], + ), + ], + }, + stages: STAGES.map((stage) => ({ + stage, + title: `${stage} title`, + description: `${stage} description`, + gate_task_id: `enabled_${stage}`, + trigger_task_id: `trigger_${stage}`, + enabling_profiles: stage === "meta" ? ["full", "metadata_backfill"] : ["full"], + dag: { + dag_id: `kitchen_ingest_${stage}`, + tasks: [ + dagTask("plan_batches"), + dagTask("process_batch", { mapped: true }), + dagTask("error_budget_gate"), + ], + edges: [ + ["plan_batches", "process_batch"], + ["process_batch", "error_budget_gate"], + ], + }, + engine_steps: stage === "meta" ? [{ name: "catalog registration", summary: "append" }] : [], + user_steps: stage === "meta" ? [userStep("cheap_check")] : [], + })), + quarantine_gate: { + from_stage: "meta", + to_stages: ["labels", "media"], + critical_step_names: ["cheap_check"], + explanation: "a False verdict from a critical check quarantines the episode", + }, + ...overrides, + }; +} + +function taskInstance( + taskId: string, + state: string | null, + mapIndex = -1, + overrides: Partial = {}, +): RunTaskInstance { + return { + task_id: taskId, + state, + start_date: "2026-08-23T10:00:00Z", + end_date: "2026-08-23T10:00:04Z", + queued_at: null, + try_number: 1, + map_index: mapIndex, + duration_s: 4, + ...overrides, + }; +} + +function runGraph( + options: { metaState?: string | null; metaRunId?: string | null; mappedStates?: string[] } = {}, +): RunGraph { + const mappedStates = options.mappedStates ?? ["success", "success"]; + const metaRunId = options.metaRunId === undefined ? "meta_run_1" : options.metaRunId; + return { + master: { + dag_run_id: "master_run_1", + state: "success", + tasks: [ + taskInstance(RESOLVE_TASK_ID, "success"), + ...STAGES.flatMap((stage) => [ + taskInstance(`enabled_${stage}`, "success"), + taskInstance(`trigger_${stage}`, "success"), + ]), + ], + }, + stages: STAGES.map((stage) => { + const isMeta = stage === "meta"; + const byState: Record = {}; + for (const state of mappedStates) byState[state] = (byState[state] ?? 0) + 1; + return { + stage, + dag_id: `kitchen_ingest_${stage}`, + dag_run_id: isMeta ? metaRunId : null, + state: isMeta ? (options.metaState ?? "success") : null, + match: isMeta && metaRunId !== null ? ("heuristic" as const) : null, + tasks: isMeta + ? [ + taskInstance("plan_batches", "success"), + ...mappedStates.map((state, index) => taskInstance("process_batch", state, index)), + taskInstance("error_budget_gate", "success"), + ] + : [], + mapped_summary: isMeta + ? { task_id: "process_batch", total: mappedStates.length, by_state: byState } + : null, + }; + }), + }; +} + +function episodePage(rows: Record[], total = rows.length): EpisodePage { + return { rows, total, columns: [], sql: "SELECT 1" }; +} + +function build( + focus: CanvasFocus, + input: { + pipeline?: PipelineGraph; + run?: RunGraph | null; + episodes?: EpisodePage | null; + dossier?: EpisodeDossier | null; + } = {}, +): CanvasGraph { + return buildGraph({ + focus, + pipeline: input.pipeline ?? pipelineGraph(), + run: input.run ?? null, + episodes: input.episodes ?? null, + dossier: input.dossier ?? null, + }); +} + +function nodeIds(graph: CanvasGraph): string[] { + return graph.nodes.map((node) => node.id); +} + +function edgePairs(graph: CanvasGraph): string[] { + return graph.edges.map((edge) => `${edge.source}->${edge.target}`); +} + +describe("the run level", () => { + it("draws one node per stage, merging that stage's gate and trigger", () => { + const graph = build({ level: "run" }); + expect(nodeIds(graph)).toEqual([RESOLVE_TASK_ID, ...STAGES]); + // Neither master task id survives as a node of its own... + for (const stage of STAGES) { + expect(nodeIds(graph)).not.toContain(`enabled_${stage}`); + expect(nodeIds(graph)).not.toContain(`trigger_${stage}`); + } + // ...but both are still named on the merged node, so nothing is hidden. + const meta = graph.nodes.find((node) => node.id === "meta"); + expect(meta?.data.detail.map((line) => line.label)).toEqual( + expect.arrayContaining(["enabled_meta", "trigger_meta"]), + ); + }); + + it("rewrites the master's chain onto the merged nodes", () => { + const graph = build({ level: "run" }); + expect(edgePairs(graph)).toContain(`${RESOLVE_TASK_ID}->sync`); + expect(edgePairs(graph)).toContain("sync->meta"); + // The gate-to-trigger edge inside one stage would be a self-edge, so it is + // dropped rather than drawn as a loop. + expect(edgePairs(graph)).not.toContain("meta->meta"); + }); + + it("states each ordering once, dropping the shortcuts the chain implies", () => { + // The master feeds every stage gate from the profile task AND chains the + // stages, so resolve->meta is already implied by resolve->sync->meta. + const graph = build({ level: "run" }); + expect(edgePairs(graph)).not.toContain(`${RESOLVE_TASK_ID}->meta`); + expect(edgePairs(graph)).not.toContain(`${RESOLVE_TASK_ID}->media`); + // The ordering survives, just stated along the chain: a straight line. + expect(edgePairs(graph).sort()).toEqual([ + "labels->media", + "meta->labels", + `${RESOLVE_TASK_ID}->sync`, + "sync->meta", + ]); + }); + + it("drills into a stage from its merged node", () => { + const graph = build({ level: "run" }); + const node = graph.nodes.find((candidate) => candidate.id === "labels"); + expect(node?.data.drillTo).toEqual({ level: "stage", stage: "labels" }); + }); + + it("offers no drill-down from a task that is not about one stage", () => { + const graph = build({ level: "run" }); + const node = graph.nodes.find((candidate) => candidate.id === RESOLVE_TASK_ID); + expect(node?.data.drillTo).toBeNull(); + }); + + it("shows a gate's skip rather than the trigger it prevented", () => { + const skipped = runGraph(); + const withSkippedSync: RunGraph = { + ...skipped, + master: { + ...skipped.master, + tasks: skipped.master.tasks.map((task) => + task.task_id === "enabled_sync" ? { ...task, state: "skipped" } : task, + ), + }, + }; + const node = build({ level: "run" }, { run: withSkippedSync }).nodes.find( + (candidate) => candidate.id === "sync", + ); + expect(node?.data.badges).toContain("skipped by profile"); + // Muted, not green: the stage did not succeed, it did not happen. + expect(node?.data.tone).toBe("muted"); + }); + + it("says so when the shape is all it is showing", () => { + expect(build({ level: "run" }).notices.join(" ")).toContain("No run selected"); + expect(build({ level: "run" }, { run: runGraph() }).notices.join(" ")).not.toContain( + "No run selected", + ); + }); + + it("colours a task from the selected run's instance, not from the topology", () => { + const failed = runGraph(); + const withFailure: RunGraph = { + ...failed, + master: { + ...failed.master, + tasks: failed.master.tasks.map((task) => + task.task_id === "trigger_meta" ? { ...task, state: "failed" } : task, + ), + }, + }; + const graph = build({ level: "run" }, { run: withFailure }); + const node = graph.nodes.find((candidate) => candidate.id === "meta"); + expect(node?.data.tone).toBe("err"); + }); + + it("hangs the recorded episodes off the end of the stage chain, dashed", () => { + const graph = build({ level: "run" }, { run: runGraph(), episodes: episodePage([], 7) }); + const node = graph.nodes.find((candidate) => candidate.id === "~episodes"); + expect(node?.data.badges).toEqual(["7"]); + expect(node?.data.drillTo).toEqual({ level: "episodes" }); + const incoming = graph.edges.filter((edge) => edge.target === "~episodes"); + // Off the chain's one sink, and dashed: the rows are appended inside the + // stage sub-DAGs, so nothing on this level really precedes them. + expect(incoming.map((edge) => edge.source)).toEqual(["media"]); + expect(incoming[0]?.dashed).toBe(true); + }); + + it("offers no episodes branch without a run to attribute them to", () => { + expect(nodeIds(build({ level: "run" }))).not.toContain("~episodes"); + }); +}); + +describe("the stage level", () => { + it("expands the mapped task into one node per instance, rewiring both sides", () => { + const graph = build({ level: "stage", stage: "meta" }, { run: runGraph() }); + expect(nodeIds(graph)).toContain("process_batch~0"); + expect(nodeIds(graph)).toContain("process_batch~1"); + expect(nodeIds(graph)).not.toContain("process_batch"); + // The fan is a real fan-out AND a real join: the plan feeds every instance, + // and the budget gate waits for all of them. + expect(edgePairs(graph)).toContain("plan_batches->process_batch~0"); + expect(edgePairs(graph)).toContain("plan_batches->process_batch~1"); + expect(edgePairs(graph)).toContain("process_batch~0->error_budget_gate"); + expect(edgePairs(graph)).toContain("process_batch~1->error_budget_gate"); + }); + + it("stacks the fan instead of drawing it once it stops being readable", () => { + const wide = runGraph({ mappedStates: Array.from({ length: 40 }, () => "success") }); + const graph = build({ level: "stage", stage: "meta" }, { run: wide }); + expect(nodeIds(graph)).toContain("process_batch"); + expect(nodeIds(graph)).not.toContain("process_batch~0"); + const stacked = graph.nodes.find((node) => node.id === "process_batch"); + expect(stacked?.data.badges).toContain("x40"); + expect(stacked?.data.badges).toContain("40 success"); + }); + + it("takes the worst state in a stacked fan, so one failure in many is visible", () => { + const mostlyFine = runGraph({ + mappedStates: [...Array.from({ length: 39 }, () => "success"), "failed"], + }); + const graph = build({ level: "stage", stage: "meta" }, { run: mostlyFine }); + expect(graph.nodes.find((node) => node.id === "process_batch")?.data.tone).toBe("err"); + }); + + it("leaves the mapped task unexpanded when no run is selected", () => { + const graph = build({ level: "stage", stage: "meta" }); + const node = graph.nodes.find((candidate) => candidate.id === "process_batch"); + expect(node?.data.badges).toContain("fans out per batch"); + expect(node?.data.drillTo).toEqual({ level: "steps", stage: "meta" }); + }); + + it("never offers an episodes branch per stage", () => { + // Deliberate: the catalog keeps one row per episode, so a stage superseded + // by a later stage of the same ingest would honestly answer "0 episodes". + // The branch lives on the run instead. + for (const stage of STAGES) { + expect(nodeIds(build({ level: "stage", stage }, { run: runGraph() }))).not.toContain( + "~episodes", + ); + } + }); + + it("says when the selected run never reached this stage", () => { + const graph = build({ level: "stage", stage: "labels" }, { run: runGraph() }); + expect(graph.notices.join(" ")).toContain("did not run"); + }); + + it("carries the heuristic attribution caveat only when a stage run was matched", () => { + const matched = build({ level: "stage", stage: "meta" }, { run: runGraph() }); + expect(matched.notices.join(" ")).toContain("matched by time window"); + const unmatched = build({ level: "stage", stage: "sync" }, { run: runGraph() }); + expect(unmatched.notices.join(" ")).not.toContain("matched by time window"); + }); +}); + +describe("the steps level", () => { + it("never draws an edge between two steps of the same tier", () => { + const pipeline = pipelineGraph(); + const meta = pipeline.stages.find((stage) => stage.stage === "meta"); + if (meta === undefined) throw new Error("fixture has no meta stage"); + const withThreeChecks: PipelineGraph = { + ...pipeline, + stages: pipeline.stages.map((stage) => + stage.stage === "meta" + ? { + ...stage, + user_steps: [userStep("check_a"), userStep("check_b"), userStep("check_c")], + } + : stage, + ), + }; + const graph = build({ level: "steps", stage: "meta" }, { pipeline: withThreeChecks }); + const stepIds = new Set(["~step:check_a", "~step:check_b", "~step:check_c"]); + for (const edge of graph.edges) { + expect(stepIds.has(edge.source) && stepIds.has(edge.target)).toBe(false); + } + expect(graph.notices.join(" ")).toContain("no ordering"); + }); + + it("separates the tiers with a barrier, because that ordering is real", () => { + const pipeline = pipelineGraph(); + const withBothTiers: PipelineGraph = { + ...pipeline, + stages: pipeline.stages.map((stage) => + stage.stage === "meta" + ? { + ...stage, + user_steps: [ + userStep("cheap", { tier: 1 }), + userStep("expensive", { tier: 2, requires: ["cheap"] }), + ], + } + : stage, + ), + }; + const graph = build({ level: "steps", stage: "meta" }, { pipeline: withBothTiers }); + expect(edgePairs(graph)).toContain("~step:cheap->~tier-barrier"); + expect(edgePairs(graph)).toContain("~tier-barrier->~step:expensive"); + }); + + it("omits the tier barrier when only one tier has steps", () => { + const graph = build({ level: "steps", stage: "meta" }); + expect(nodeIds(graph)).not.toContain("~tier-barrier"); + }); + + it("puts meta's catalog append after the checks and the gate", () => { + const graph = build({ level: "steps", stage: "meta" }); + const ids = nodeIds(graph); + expect(ids.indexOf("~step:cheap_check")).toBeLessThan(ids.indexOf("~quarantine:decision")); + expect(ids.indexOf("~quarantine:decision")).toBeLessThan( + ids.indexOf("~engine:catalog registration"), + ); + }); + + it("draws the quarantine gate as an ENTRY condition on the receiving stages", () => { + const labels = build({ level: "steps", stage: "labels" }); + expect(nodeIds(labels)).toContain("~quarantine:entry"); + expect(nodeIds(labels)).not.toContain("~quarantine:decision"); + const meta = build({ level: "steps", stage: "meta" }); + expect(nodeIds(meta)).toContain("~quarantine:decision"); + expect(nodeIds(meta)).not.toContain("~quarantine:entry"); + }); + + it("refuses to guess when the server has no pipeline imported", () => { + const graph = build( + { level: "steps", stage: "meta" }, + { pipeline: pipelineGraph({ steps_known: false }) }, + ); + expect(graph.nodes).toHaveLength(0); + expect(graph.emptyMessage).toContain("--pipeline"); + }); + + it("carries a step's gate onto the node rather than just the critical flag", () => { + const pipeline = pipelineGraph(); + const gated: PipelineGraph = { + ...pipeline, + stages: pipeline.stages.map((stage) => + stage.stage === "meta" + ? { + ...stage, + user_steps: [ + userStep("blur", { + critical: true, + gate: { + accept_when: [ + { + key_pattern: "blur_fraction", + comparison: "at_most", + value: 0.3, + across: "every_key", + }, + ], + }, + }), + ], + } + : stage, + ), + }; + const graph = build({ level: "steps", stage: "meta" }, { pipeline: gated }); + const node = graph.nodes.find((candidate) => candidate.id === "~step:blur"); + expect(node?.data.subtitle).toBe("blur_fraction <= 0.3 (every key)"); + expect(node?.data.badges).toContain("critical"); + }); +}); + +describe("the episodes level", () => { + it("fans the recorded episodes off the master run and drills into each", () => { + const graph = build( + { level: "episodes" }, + { + run: runGraph(), + episodes: episodePage([ + { episode_id: "abcdef0123456789", status: "ok", task: "pour" }, + { episode_id: "fedcba9876543210", status: "quarantined", task: "pour" }, + ]), + }, + ); + expect(nodeIds(graph)).toContain("~episode:abcdef0123456789"); + expect(edgePairs(graph)).toContain("~run->~episode:abcdef0123456789"); + const quarantined = graph.nodes.find((node) => node.id === "~episode:fedcba9876543210"); + expect(quarantined?.data.tone).toBe("warn"); + expect(quarantined?.data.drillTo).toEqual({ + level: "episode", + episodeId: "fedcba9876543210", + }); + }); + + it("says how many episodes it is NOT showing rather than silently truncating", () => { + const graph = build( + { level: "episodes" }, + { run: runGraph(), episodes: episodePage([{ episode_id: "abc", status: "ok" }], 900) }, + ); + expect(graph.notices.join(" ")).toContain("first 1 of 900"); + }); + + it("explains an empty result, naming re-ingest as one reason for it", () => { + const graph = build({ level: "episodes" }, { run: runGraph(), episodes: episodePage([]) }); + expect(graph.nodes).toHaveLength(0); + expect(graph.emptyMessage).toContain("re-ingested"); + }); +}); + +describe("the episode query's scope", () => { + it("is every matched stage run of the selected master run", () => { + // Deliberately the union: the catalog keeps one row per episode, so asking + // with a single stage's id answers 0 for every stage the same ingest later + // superseded. + expect(stageRunIds(runGraph())).toEqual(["meta_run_1"]); + expect(stageRunIds(null)).toEqual([]); + }); +}); + +describe("the episode level", () => { + function dossier(overrides: Partial = {}): EpisodeDossier { + return { + episode: { status: "ok", quarantine_tags: [], episode_id: "abc", task: "pour" }, + check_runs: [ + { + check_name: "cheap_check", + check_version: "v-cheap_check", + critical: false, + status: "passed", + duration_s: 0.2, + error: null, + recorded_at: "2026-08-23T10:00:00Z", + run_fingerprint: "fp", + }, + ], + measurements: [], + intervals: [], + tags: [], + history: [], + media: [], + canonical_url: null, + ...overrides, + }; + } + + it("reads a check's verdict from the catalog and its gate from the live pipeline", () => { + const pipeline = pipelineGraph(); + const gated: PipelineGraph = { + ...pipeline, + stages: pipeline.stages.map((stage) => + stage.stage === "meta" + ? { + ...stage, + user_steps: [ + userStep("cheap_check", { + gate: { + accept_when: [ + { + key_pattern: "gap_*", + comparison: "at_least", + value: 2, + across: "any_key", + }, + ], + }, + }), + ], + } + : stage, + ), + }; + const graph = build( + { level: "episode", episodeId: "abc" }, + { pipeline: gated, dossier: dossier() }, + ); + const node = graph.nodes.find((candidate) => candidate.id === "~check:tier 1:cheap_check"); + expect(node?.data.tone).toBe("ok"); + expect(node?.data.subtitle).toBe("gap_* >= 2 (any key)"); + }); + + it("shows a recorded check the pipeline no longer registers instead of dropping it", () => { + const graph = build( + { level: "episode", episodeId: "abc" }, + { + dossier: dossier({ + check_runs: [ + { + check_name: "deleted_check", + check_version: "old", + critical: false, + status: "passed", + duration_s: 1, + error: null, + recorded_at: null, + run_fingerprint: null, + }, + ], + }), + }, + ); + const node = graph.nodes.find( + (candidate) => candidate.id === "~check:not registered:deleted_check", + ); + expect(node?.data.badges).toContain("not registered"); + expect(graph.notices.join(" ")).toContain( + "1 recorded check no longer exists in the current pipeline", + ); + }); + + it("keeps a measured check distinct from a passed one", () => { + const graph = build( + { level: "episode", episodeId: "abc" }, + { + dossier: dossier({ + check_runs: [ + { + check_name: "cheap_check", + check_version: "v", + critical: false, + status: "measured", + duration_s: 1, + error: null, + recorded_at: null, + run_fingerprint: null, + }, + ], + }), + }, + ); + expect(graph.nodes.find((node) => node.id === "~check:tier 1:cheap_check")?.data.tone).toBe( + "info", + ); + }); + + it("lists a check's own measurements, elided past the display limit", () => { + const graph = build( + { level: "episode", episodeId: "abc" }, + { + dossier: dossier({ + measurements: Array.from({ length: 9 }, (_unused, index) => ({ + key: `key_${index}`, + value_double: index, + value_text: null, + value_bool: null, + check_name: "cheap_check", + check_version: "v", + recorded_at: null, + })), + }), + }, + ); + const node = graph.nodes.find((candidate) => candidate.id === "~check:tier 1:cheap_check"); + const measurements = node?.data.detail.find((line) => line.label === "measurements"); + expect(measurements?.value).toContain("key_0 = 0"); + expect(measurements?.value).toContain("and 3 more"); + }); +}); diff --git a/ui/src/canvas/buildGraph.ts b/ui/src/canvas/buildGraph.ts new file mode 100644 index 0000000..eccdcfe --- /dev/null +++ b/ui/src/canvas/buildGraph.ts @@ -0,0 +1,1079 @@ +// The whole canvas, as a pure function of one focus and the payloads it needs. +// +// Nothing here fetches, lays out, or renders: it turns server payloads into +// nodes and edges, which is the only part of this UI with real decisions in it +// (which drill-down a node offers, which edges are honest to draw, what a +// missing run means). Keeping it pure is what makes those decisions testable +// without a browser -- see buildGraph.test.ts. +// +// ONE RULE runs through every level: an edge means a real dependency. The +// server is explicit that the pipeline's own steps have no dependency edges on +// each other (hflow_server._graph's module docstring), so this never draws +// arrows between them. Where an ordering does exist -- the stage chain, the +// tier boundary, the quarantine gate -- it is drawn, and everything else is +// grouped instead. + +import type { + EpisodeCheckRun, + EpisodeDossier, + EpisodePage, + PipelineGate, + PipelineGraph, + PipelineGraphStage, + PipelineUserStep, + RunGraph, + RunGraphStage, + RunTaskInstance, + Stage, +} from "../api"; +import { airflowStateTone, checkStatusTone, type Tone } from "../tones"; +import type { CanvasFocus } from "./focus"; +import { EPISODES_FOCUS, shortEpisodeId } from "./focus"; + +// These are type ALIASES, not interfaces, and that is load-bearing: React +// Flow's Node requires the node data to satisfy Record, and +// TypeScript grants that implicit index signature to an object type alias but +// never to an interface. + +/** A label/value pair for the inspector panel. */ +export type DetailLine = { + readonly label: string; + readonly value: string; +}; + +/** + * What a node draws. + * + * - `task` an orchestrator task instance, identified by its task id + * - `item` a piece of data or work: an episode, a check, a pipeline step + * - `note` structural furniture: an anchor, or a barrier between groups + */ +export type NodeShape = "task" | "item" | "note"; + +export type CanvasNodeData = { + readonly title: string; + readonly subtitle: string | null; + readonly tone: Tone; + readonly shape: NodeShape; + /** Short pills on the node itself: a retry count, a duration, "critical". */ + readonly badges: readonly string[]; + /** The inspector's content for this node. */ + readonly detail: readonly DetailLine[]; + /** Where clicking this node goes. Null makes it a leaf. */ + readonly drillTo: CanvasFocus | null; +}; + +export type CanvasNode = { + readonly id: string; + readonly data: CanvasNodeData; +}; + +export type CanvasEdge = { + readonly id: string; + readonly source: string; + readonly target: string; + /** Dashed means "a real relationship that is not an execution dependency". */ + readonly dashed: boolean; + readonly label: string | null; +}; + +export type CanvasGraph = { + readonly nodes: readonly CanvasNode[]; + readonly edges: readonly CanvasEdge[]; + /** Why the canvas is empty. Set only when there are no nodes at all. */ + readonly emptyMessage: string | null; + /** Caveats about what IS drawn, shown above the canvas. */ + readonly notices: readonly string[]; +}; + +export type CanvasInput = { + readonly focus: CanvasFocus; + readonly pipeline: PipelineGraph; + /** The selected master run's live state, or null when none is selected. */ + readonly run: RunGraph | null; + /** Episodes recorded by the selected run's stages. Null when not loaded. */ + readonly episodes: EpisodePage | null; + /** The focused episode's dossier. Null outside the `episode` level. */ + readonly dossier: EpisodeDossier | null; +}; + +// Synthetic node ids are prefixed so they can never collide with an Airflow +// task id: Airflow restricts task ids to alphanumerics, dash, dot and +// underscore, so "~" is unavailable to them and available to us. +const SYNTHETIC = "~"; + +// Above this many mapped instances the fan-out is drawn as one stacked node +// carrying the counts instead of one node each. Twelve is where a fan stops +// being readable as individual boxes and starts being a wall; the stacked node +// loses no information, because the server already serves the complete state +// split in `mapped_summary.by_state`. +const MAX_DRAWN_FAN_NODES = 12; + +/** How many of a check's measurements the inspector lists before eliding. */ +const MAX_LISTED_MEASUREMENTS = 6; + +const EMPTY_GRAPH_NOTICES: readonly string[] = []; + +export function buildGraph(input: CanvasInput): CanvasGraph { + switch (input.focus.level) { + case "run": + return runLevel(input); + case "stage": + return stageLevel(input, input.focus.stage); + case "steps": + return stepsLevel(input, input.focus.stage); + case "episodes": + return episodesLevel(input); + case "episode": + return episodeLevel(input, input.focus.episodeId); + } +} + +// --- level 1: the master run ------------------------------------------------ + +// The master task that is not about any one stage: it reads the trigger conf +// and publishes which stages this profile enables. Identified by elimination +// (every other master task belongs to a stage), so no task id is restated here. + +/** + * The run as a chain of stages: resolve the profile, then each stage in turn. + * + * ONE SUMMARY IS MADE HERE, deliberately. The master DAG spends two tasks per + * stage -- ``enabled_`` decides whether the profile runs it, then + * ``trigger_`` fires the sub-DAG and defers until it finishes -- and + * this level draws them as one node per stage. Both task ids, both states and + * both durations are on that node, so nothing is hidden; what is gained is a + * five-node chain a reader takes in at once instead of a nine-rank ribbon that + * only fits on screen at a zoom nobody can read. The full task-by-task picture + * of a stage is one drill-down away. + */ +function runLevel({ pipeline, run, episodes }: CanvasInput): CanvasGraph { + const instanceByTaskId = new Map(); + for (const task of run?.master.tasks ?? []) { + if (task.task_id !== null) instanceByTaskId.set(task.task_id, task); + } + const stageTaskIds = new Set( + pipeline.stages.flatMap((stage) => [stage.gate_task_id, stage.trigger_task_id]), + ); + + const nodes: CanvasNode[] = pipeline.master.tasks + .filter((task) => !stageTaskIds.has(task.task_id)) + .map((task): CanvasNode => { + const instance = instanceByTaskId.get(task.task_id) ?? null; + return { + id: task.task_id, + data: { + title: task.task_id, + subtitle: task.summary, + tone: airflowStateTone(instance?.state), + shape: "task", + badges: taskInstanceBadges(instance), + detail: taskInstanceDetail(instance), + drillTo: null, + }, + }; + }); + const stageNodeIds = new Map(); + for (const stagePipeline of pipeline.stages) { + const node = stageChainNode(stagePipeline, instanceByTaskId); + stageNodeIds.set(stagePipeline.stage, node.id); + nodes.push(node); + } + + // Every master edge, rewritten onto the merged stage nodes. An edge between + // one stage's two tasks collapses into a self-edge and is dropped; the rest + // keep the chain exactly as the master declares it. + const nodeIdForTaskId = (taskId: string): string => { + for (const stagePipeline of pipeline.stages) { + if (taskId === stagePipeline.gate_task_id || taskId === stagePipeline.trigger_task_id) { + return stageNodeIds.get(stagePipeline.stage) ?? taskId; + } + } + return taskId; + }; + const rewritten: CanvasEdge[] = []; + const seenEdges = new Set(); + for (const [source, target] of pipeline.master.edges) { + const from = nodeIdForTaskId(source); + const to = nodeIdForTaskId(target); + const id = `${from}->${to}`; + if (from === to || seenEdges.has(id)) continue; + seenEdges.add(id); + rewritten.push({ id, source: from, target: to, dashed: false, label: null }); + } + const edges = withoutRedundantEdges(rewritten); + + // The data branch, hung off the end of the stage chain. Dashed, because the + // rows are written inside the stage sub-DAGs rather than by any master task, + // so this is a real relationship and not an Airflow dependency. + if (run !== null) { + const recordedTotal = episodes?.total ?? null; + nodes.push({ + id: `${SYNTHETIC}episodes`, + data: { + title: "episodes recorded", + subtitle: "catalog rows stamped with one of this run's stage run ids", + tone: recordedTotal === 0 ? "muted" : "info", + shape: "item", + badges: recordedTotal === null ? [] : [`${recordedTotal}`], + detail: [ + { label: "run", value: run.master.dag_run_id }, + ...(recordedTotal === null ? [] : [{ label: "rows", value: String(recordedTotal) }]), + { + label: "how this is counted", + value: + "an episode's row is the LATEST append, so a later run re-ingesting " + + "an episode takes it out of this list", + }, + ], + drillTo: EPISODES_FOCUS, + }, + }); + for (const sink of sinkNodeIds( + nodes.map((node) => node.id).filter((nodeId) => nodeId !== `${SYNTHETIC}episodes`), + edges.map((edge) => [edge.source, edge.target] as const), + )) { + edges.push({ + id: `${sink}->episodes`, + source: sink, + target: `${SYNTHETIC}episodes`, + dashed: true, + label: "recorded", + }); + } + } + + return { + nodes, + edges, + emptyMessage: null, + notices: run === null ? ["No run selected: this is the DAG's shape, not a run."] : [], + }; +} + +/** + * Drop every edge whose ordering another path already states. + * + * The master declares both a chain and a shortcut: the profile task feeds all + * four stage gates, AND each stage's trigger feeds the next stage's gate. Every + * shortcut is therefore implied by the chain, and drawing both turns a + * five-node line into a tangle. + * + * This never adds an ordering and never removes one -- a dropped edge's + * dependency still holds along the path that kept it -- so the picture stays + * true while saying it once instead of twice. It is the only place this file + * removes a real edge, and it is safe exactly because redundancy is the test. + */ +function withoutRedundantEdges(edges: readonly CanvasEdge[]): CanvasEdge[] { + const successors = new Map(); + for (const edge of edges) { + const existing = successors.get(edge.source); + if (existing === undefined) successors.set(edge.source, [edge.target]); + else existing.push(edge.target); + } + + function reachableAvoidingDirectHop(from: string, to: string): boolean { + // Breadth-first from `from`'s successors, never using the from->to hop + // itself: any OTHER route proves the direct edge redundant. + const queue = (successors.get(from) ?? []).filter((next) => next !== to); + const seen = new Set(queue); + while (queue.length > 0) { + const current = queue.shift() as string; + if (current === to) return true; + for (const next of successors.get(current) ?? []) { + if (!seen.has(next)) { + seen.add(next); + queue.push(next); + } + } + } + return false; + } + + return edges.filter((edge) => !reachableAvoidingDirectHop(edge.source, edge.target)); +} + +/** One stage as the run level draws it: its gate and its trigger, merged. */ +function stageChainNode( + stagePipeline: PipelineGraphStage, + instanceByTaskId: Map, +): CanvasNode { + const gate = instanceByTaskId.get(stagePipeline.gate_task_id) ?? null; + const trigger = instanceByTaskId.get(stagePipeline.trigger_task_id) ?? null; + // A gate that skipped means the profile did not enable this stage, and then + // the trigger never ran -- so the gate is the state worth showing. Otherwise + // the trigger is, because it is the task that waits for the stage's work. + const skippedByProfile = gate?.state?.toLowerCase() === "skipped"; + const governing = skippedByProfile ? gate : (trigger ?? gate); + return { + id: stagePipeline.stage, + data: { + title: stagePipeline.title, + subtitle: stagePipeline.description, + tone: airflowStateTone(governing?.state), + shape: "task", + badges: [ + stagePipeline.stage, + ...(skippedByProfile ? ["skipped by profile"] : []), + ...taskInstanceBadges(skippedByProfile ? gate : trigger), + ...(stagePipeline.enabling_profiles.length === 1 + ? // Only worth a pill when the stage is NOT universal: "full" runs + // everything, so saying so on all four would be noise. + [`only ${stagePipeline.enabling_profiles[0]}`] + : []), + ].filter((badge): badge is string => badge !== undefined), + detail: [ + { label: "stage", value: stagePipeline.stage }, + { label: "enabled by profiles", value: stagePipeline.enabling_profiles.join(", ") }, + { label: "sub-dag", value: stagePipeline.dag.dag_id }, + // Both master tasks named, so the merge above hides no task id. + { + label: stagePipeline.gate_task_id, + value: gate === null ? "no instance for this run" : (gate.state ?? "not scheduled yet"), + }, + { + label: stagePipeline.trigger_task_id, + value: + trigger === null ? "no instance for this run" : (trigger.state ?? "not scheduled yet"), + }, + ...taskInstanceDetail(trigger), + ], + drillTo: { level: "stage", stage: stagePipeline.stage }, + }, + }; +} + +// --- level 2: one stage's sub-DAG ------------------------------------------- + +/** + * One stage: plan the batches, fan `process_batch` out over them, close on a + * budget gate. The fan is the only mapped task, and it is where the pipeline's + * own steps run -- so it is the node that drills further in. + */ +function stageLevel(input: CanvasInput, stage: Stage): CanvasGraph { + const stagePipeline = findStage(input.pipeline, stage); + if (stagePipeline === null) return unknownStage(stage); + const runStage = findRunStage(input.run, stage); + + const mappedTaskId = stagePipeline.dag.tasks.find((task) => task.mapped)?.task_id ?? null; + const instancesByTaskId = new Map(); + for (const task of runStage?.tasks ?? []) { + if (task.task_id === null) continue; + const existing = instancesByTaskId.get(task.task_id); + if (existing === undefined) instancesByTaskId.set(task.task_id, [task]); + else existing.push(task); + } + + const nodes: CanvasNode[] = []; + // Every id the mapped task was expanded into, so the edges that pointed at + // the mapped task can be rewired onto all of them. + const fanNodeIds: string[] = []; + + for (const task of stagePipeline.dag.tasks) { + const instances = instancesByTaskId.get(task.task_id) ?? []; + if (task.task_id !== mappedTaskId) { + const instance = instances[0] ?? null; + nodes.push({ + id: task.task_id, + data: { + title: task.task_id, + subtitle: task.summary, + tone: airflowStateTone(instance?.state), + shape: "task", + badges: taskInstanceBadges(instance), + detail: taskInstanceDetail(instance), + drillTo: null, + }, + }); + continue; + } + const fanNodes = fanOutNodes(task.task_id, task.summary, instances, runStage, stage); + nodes.push(...fanNodes); + fanNodeIds.push(...fanNodes.map((node) => node.id)); + } + + const edges: CanvasEdge[] = []; + for (const [source, target] of stagePipeline.dag.edges) { + const sources = source === mappedTaskId ? fanNodeIds : [source]; + const targets = target === mappedTaskId ? fanNodeIds : [target]; + for (const from of sources) { + for (const to of targets) { + edges.push({ id: `${from}->${to}`, source: from, target: to, dashed: false, label: null }); + } + } + } + + return { + nodes, + edges, + emptyMessage: null, + notices: [ + ...(input.run === null ? ["No run selected: this is the DAG's shape, not a run."] : []), + ...(input.run !== null && runStage?.dag_run_id == null + ? [`This stage did not run for the selected master run.`] + : []), + ...(runStage?.match === "heuristic" + ? [ + "Airflow stores no link from a stage run back to the master run that " + + "triggered it, so this stage run was matched by time window and could " + + "belong to an overlapping master run.", + ] + : []), + ], + }; +} + +/** + * The mapped task, expanded. + * + * One node per mapped instance while the fan is small enough to read, and one + * stacked node carrying the state split once it is not. Before Airflow expands + * the fan it reports a single instance at map index -1, which is drawn as + * itself: "one unexpanded instance" is the truth at that moment. + */ +function fanOutNodes( + taskId: string, + summary: string, + instances: readonly RunTaskInstance[], + runStage: RunGraphStage | null, + stage: Stage, +): CanvasNode[] { + const drillTo: CanvasFocus = { level: "steps", stage }; + const summarized = runStage?.mapped_summary ?? null; + + if (instances.length === 0 || instances.length > MAX_DRAWN_FAN_NODES) { + const total = summarized?.total ?? instances.length; + return [ + { + id: taskId, + data: { + title: taskId, + subtitle: summary, + tone: stackedFanTone(summarized?.by_state), + shape: "task", + badges: [ + ...(total > 0 ? [`x${total}`] : ["fans out per batch"]), + ...Object.entries(summarized?.by_state ?? {}).map( + ([state, count]) => `${count} ${state}`, + ), + ], + detail: [ + { label: "mapped instances", value: total > 0 ? String(total) : "not planned yet" }, + ...Object.entries(summarized?.by_state ?? {}).map(([state, count]) => ({ + label: state, + value: String(count), + })), + ], + drillTo, + }, + }, + ]; + } + + return instances.map((instance) => ({ + // The map index is part of the id: two instances of one mapped task differ + // only by it, so leaving it out would collapse the whole fan into one node. + id: `${taskId}${SYNTHETIC}${instance.map_index}`, + data: { + title: instance.map_index < 0 ? taskId : `${taskId} [${instance.map_index}]`, + subtitle: instance.map_index < 0 ? summary : "one batch of episodes", + tone: airflowStateTone(instance.state), + shape: "task", + badges: taskInstanceBadges(instance), + detail: taskInstanceDetail(instance), + drillTo, + }, + })); +} + +/** + * One tone for a whole fan: the worst thing any instance is saying. + * + * Ordered worst-first so a single failure in a hundred successes still colours + * the stacked node -- the opposite (a majority vote) would hide exactly the + * instance somebody opened the page to find. + */ +function stackedFanTone(byState: Record | undefined): Tone { + const tones = new Set( + Object.entries(byState ?? {}) + .filter(([, count]) => count > 0) + .map(([state]) => airflowStateTone(state)), + ); + for (const candidate of ["err", "warn", "run", "ok", "info"] as const) { + if (tones.has(candidate)) return candidate; + } + return "muted"; +} + +/** The nodes nothing depends on: where a DAG's work ends. */ +function sinkNodeIds( + nodeIds: readonly string[], + edges: readonly (readonly [string, string])[], +): string[] { + const withOutgoing = new Set(edges.map(([source]) => source)); + return nodeIds.filter((nodeId) => !withOutgoing.has(nodeId)); +} + +// --- level 3: the steps inside one batch ------------------------------------ + +/** + * What one `process_batch` does to each episode in its batch. + * + * Drawn as groups separated by the boundaries that are real, never as a chain: + * the engine runs a stage's registered steps in tier order, so every tier-2 + * step runs after every tier-1 step, but steps WITHIN a tier have no ordering + * and no dependency on each other. So a tier is a column, and only the + * boundaries get arrows. + */ +function stepsLevel(input: CanvasInput, stage: Stage): CanvasGraph { + const stagePipeline = findStage(input.pipeline, stage); + if (stagePipeline === null) return unknownStage(stage); + if (!input.pipeline.steps_known) { + return { + nodes: [], + edges: [], + emptyMessage: + "This server was started without --pipeline, so what runs inside a batch " + + "is unknown to it. Restart `hflow serve` with --pipeline path/to/pipeline.py.", + notices: EMPTY_GRAPH_NOTICES, + }; + } + + const columns: CanvasNode[][] = []; + const anchor: CanvasNode = { + id: `${SYNTHETIC}batch`, + data: { + title: "one episode", + subtitle: `everything below runs per episode, inside this stage's process_batch`, + tone: "muted", + shape: "note", + badges: [], + detail: [{ label: "stage", value: `${stagePipeline.title} (${stage})` }], + drillTo: null, + }, + }; + columns.push([anchor]); + + // Labels and media are the RECEIVING side of the quarantine gate: for them it + // is an entry condition, so it is drawn before their steps. Meta is the + // deciding side, so its gate comes after the checks, below. + const gate = input.pipeline.quarantine_gate; + if (gate?.to_stages.includes(stage)) { + columns.push([quarantineGateNode(gate, "entry")]); + } + + const engineNodes = stagePipeline.engine_steps.map( + (step): CanvasNode => ({ + id: `${SYNTHETIC}engine:${step.name}`, + data: { + title: step.name, + subtitle: step.summary, + tone: "info", + shape: "item", + badges: ["engine"], + detail: [{ label: "owned by", value: "the engine, not a registration" }], + drillTo: null, + }, + }), + ); + + // Meta is the one stage with both, and there the engine's work is the catalog + // append, which records what the checks decided -- so it runs last. Every + // other stage's engine step is the only thing in it, and reads first. + const engineStepsRunLast = stage === "meta"; + if (engineNodes.length > 0 && !engineStepsRunLast) columns.push(engineNodes); + + for (const tier of [1, 2] as const) { + const inTier = stagePipeline.user_steps.filter((step) => step.tier === tier); + if (inTier.length === 0) continue; + if (tier === 2) { + columns.push([ + { + id: `${SYNTHETIC}tier-barrier`, + data: { + title: "tier 1 complete", + subtitle: "steps declaring requires or uses run in the second tier", + tone: "muted", + shape: "note", + badges: [], + detail: [ + { + label: "why", + value: + "the engine sorts a stage's steps by tier and runs them in that " + + "order, so every tier-2 step runs after every tier-1 step", + }, + ], + drillTo: null, + }, + }, + ]); + } + columns.push(inTier.map((step) => userStepNode(step, tier))); + } + + if (gate !== null && gate.from_stage === stage) { + columns.push([quarantineGateNode(gate, "decision")]); + } + if (engineNodes.length > 0 && engineStepsRunLast) columns.push(engineNodes); + + const nonEmpty = columns.filter((column) => column.length > 0); + const userStepCount = stagePipeline.user_steps.length; + return { + nodes: nonEmpty.flat(), + edges: chainColumns(nonEmpty), + emptyMessage: null, + notices: [ + ...(userStepCount === 0 + ? [`This pipeline registers no steps in the ${stage} stage; the work here is the engine's.`] + : []), + "Steps within one column have no ordering and no dependency on each other. " + + "Only the boundaries between columns are real.", + ], + }; +} + +function userStepNode(step: PipelineUserStep, tier: 1 | 2): CanvasNode { + const gateText = step.gate == null ? null : gateSummary(step.gate); + return { + id: `${SYNTHETIC}step:${step.name}`, + data: { + title: step.name, + subtitle: gateText, + tone: step.critical ? "warn" : "info", + shape: "item", + badges: [ + step.kind, + ...(step.critical ? ["critical"] : []), + ...(tier === 2 ? ["tier 2"] : []), + ], + detail: [ + { label: "kind", value: step.kind }, + { label: "version", value: step.version }, + { label: "critical", value: step.critical ? "yes: a False verdict quarantines" : "no" }, + ...(gateText === null ? [] : [{ label: "accepts when", value: gateText }]), + ...(step.requires.length > 0 + ? [{ label: "requires", value: step.requires.join(", ") }] + : []), + ...(step.uses == null ? [] : [{ label: "uses", value: step.uses }]), + ], + drillTo: null, + }, + }; +} + +function quarantineGateNode( + gate: NonNullable, + side: "entry" | "decision", +): CanvasNode { + const hasCriticalSteps = gate.critical_step_names.length > 0; + return { + id: `${SYNTHETIC}quarantine:${side}`, + data: { + title: "quarantine gate", + subtitle: + side === "entry" + ? "a quarantined episode records every step below as skipped" + : "a critical check's False verdict quarantines the episode", + tone: hasCriticalSteps ? "warn" : "muted", + shape: "note", + badges: hasCriticalSteps ? [`${gate.critical_step_names.length} critical`] : ["no gate"], + detail: [ + { label: "explanation", value: gate.explanation }, + { + label: "critical steps", + value: hasCriticalSteps ? gate.critical_step_names.join(", ") : "none registered", + }, + { label: "affects stages", value: gate.to_stages.join(", ") }, + ], + drillTo: null, + }, + }; +} + +/** One threshold set as a single readable line. */ +export function gateSummary(gate: PipelineGate): string { + return gate.accept_when + .map((threshold) => { + const operator = threshold.comparison === "at_most" ? "<=" : ">="; + const scope = threshold.across === "any_key" ? "any key" : "every key"; + return `${threshold.key_pattern} ${operator} ${threshold.value} (${scope})`; + }) + .join(" and "); +} + +/** + * Wire consecutive columns together. + * + * Every boundary this crosses has a single-node column on at least one side (a + * barrier, a gate, the anchor), so the edge count stays linear in the nodes + * rather than multiplying two columns together. + */ +function chainColumns(columns: readonly CanvasNode[][]): CanvasEdge[] { + const edges: CanvasEdge[] = []; + for (let index = 0; index + 1 < columns.length; index += 1) { + for (const from of columns[index] ?? []) { + for (const to of columns[index + 1] ?? []) { + edges.push({ + id: `${from.id}->${to.id}`, + source: from.id, + target: to.id, + dashed: false, + label: null, + }); + } + } + } + return edges; +} + +// --- level 4: the episodes this run recorded -------------------------------- + +/** + * The episodes whose current catalog row came out of this run. + * + * Two things this deliberately does NOT claim. Which BATCH produced which + * episode is not drawable: Airflow reports the mapped instances, the catalog + * records the run, and nothing ties an episode to a map index. And which STAGE + * recorded it is not a useful split either, because the catalog keeps one row + * per episode and the last stage to append wins -- so the fan here is from the + * master run, over the union of its stage runs. + */ +function episodesLevel(input: CanvasInput): CanvasGraph { + const run = input.run; + if (run === null) { + return { + nodes: [], + edges: [], + emptyMessage: "No run is selected, so there are no recorded episodes to show.", + notices: EMPTY_GRAPH_NOTICES, + }; + } + const rows = input.episodes?.rows ?? []; + if (input.episodes !== null && rows.length === 0) { + return { + nodes: [], + edges: [], + emptyMessage: + "No episode's current catalog row came out of this run. A run that failed " + + "before process_batch appends nothing, and an episode a LATER run " + + "re-ingested now belongs to that run instead.", + notices: EMPTY_GRAPH_NOTICES, + }; + } + + const anchorId = `${SYNTHETIC}run`; + const nodes: CanvasNode[] = [ + { + id: anchorId, + data: { + title: run.master.dag_run_id, + subtitle: "ingest run", + tone: airflowStateTone(run.master.state), + shape: "note", + badges: [`${input.episodes?.total ?? rows.length} episodes`], + detail: [ + { label: "state", value: run.master.state ?? "unknown" }, + { + label: "stage runs", + value: + stageRunIds(run).join(", ") || + "none matched, so nothing could be attributed to this run", + }, + ], + drillTo: null, + }, + }, + ]; + const edges: CanvasEdge[] = []; + + for (const row of rows) { + const episodeId = textField(row, "episode_id"); + if (episodeId === null) continue; + const status = textField(row, "status"); + nodes.push({ + id: `${SYNTHETIC}episode:${episodeId}`, + data: { + title: shortEpisodeId(episodeId), + subtitle: textField(row, "task"), + tone: status === "quarantined" ? "warn" : "ok", + shape: "item", + badges: [...(status === null ? [] : [status])], + detail: [ + { label: "episode_id", value: episodeId }, + ...[ + "task", + "operator", + "embodiment", + "success", + "recorded_at", + "pipeline_version", + "orchestrator_run_id", + ] + .map((column) => ({ label: column, value: textField(row, column) })) + .filter((line): line is DetailLine => line.value !== null), + ], + drillTo: { level: "episode", episodeId }, + }, + }); + edges.push({ + id: `${anchorId}->${episodeId}`, + source: anchorId, + target: `${SYNTHETIC}episode:${episodeId}`, + dashed: true, + label: null, + }); + } + + const total = input.episodes?.total ?? 0; + return { + nodes, + edges, + emptyMessage: null, + notices: + total > rows.length + ? [`Showing the first ${rows.length} of ${total} episodes this run recorded.`] + : EMPTY_GRAPH_NOTICES, + }; +} + +/** + * Every stage run id this master run was matched to. + * + * The set the episode query filters on, exported so App.tsx asks for exactly + * what this level draws rather than deriving the same list a second way. + */ +export function stageRunIds(run: RunGraph | null): string[] { + return (run?.stages ?? []) + .map((stage) => stage.dag_run_id) + .filter((runId): runId is string => runId !== null); +} + +// --- level 5: one episode's checks ------------------------------------------ + +/** + * Every check recorded for one episode, with its verdict and its gate. + * + * The tiers come from the live pipeline and the verdicts from the catalog, so a + * check recorded by an earlier version of the pipeline can appear with no tier. + * That is shown rather than hidden: it is the honest signal that the recorded + * evidence and the current code have diverged. + */ +function episodeLevel(input: CanvasInput, episodeId: string): CanvasGraph { + const dossier = input.dossier; + if (dossier === null) { + return { + nodes: [], + edges: [], + emptyMessage: `Loading episode ${shortEpisodeId(episodeId)}...`, + notices: EMPTY_GRAPH_NOTICES, + }; + } + + const stepsByName = new Map(); + for (const stagePipeline of input.pipeline.stages) { + for (const step of stagePipeline.user_steps) stepsByName.set(step.name, step); + } + const measurementsByCheck = new Map(); + for (const measurement of dossier.measurements) { + if (measurement.check_name === null || measurement.key === null) continue; + const rendered = `${measurement.key} = ${measurementValueText(measurement)}`; + const existing = measurementsByCheck.get(measurement.check_name); + if (existing === undefined) measurementsByCheck.set(measurement.check_name, [rendered]); + else existing.push(rendered); + } + + const anchorId = `${SYNTHETIC}episode`; + const quarantineTags = dossier.episode.quarantine_tags; + const nodes: CanvasNode[] = [ + { + id: anchorId, + data: { + title: shortEpisodeId(episodeId), + subtitle: dossier.episode.status === "quarantined" ? "quarantined" : "ok", + tone: dossier.episode.status === "quarantined" ? "warn" : "ok", + shape: "note", + badges: [`${dossier.check_runs.length} checks`], + detail: [ + { label: "episode_id", value: episodeId }, + { label: "status", value: dossier.episode.status }, + ...(quarantineTags.length > 0 + ? [{ label: "quarantine tags", value: quarantineTags.join(", ") }] + : []), + ...["task", "operator", "embodiment", "pipeline_version", "orchestrator_run_id"] + .map((column) => ({ label: column, value: textField(dossier.episode, column) })) + .filter((line): line is DetailLine => line.value !== null), + ], + drillTo: null, + }, + }, + ]; + + // Grouped by the tier the CURRENT pipeline puts each check in; a recorded + // check the pipeline no longer registers has no tier and gets its own group. + const groups: { readonly label: string; readonly runs: EpisodeCheckRun[] }[] = [ + { label: "tier 1", runs: [] }, + { label: "tier 2", runs: [] }, + { label: "not registered", runs: [] }, + ]; + for (const checkRun of dossier.check_runs) { + const step = checkRun.check_name === null ? undefined : stepsByName.get(checkRun.check_name); + const groupIndex = step === undefined ? 2 : step.tier - 1; + groups[groupIndex]?.runs.push(checkRun); + } + + const edges: CanvasEdge[] = []; + for (const group of groups) { + for (const checkRun of group.runs) { + const name = checkRun.check_name ?? "(unnamed check)"; + const step = stepsByName.get(name); + const gateText = step?.gate == null ? null : gateSummary(step.gate); + const nodeId = `${SYNTHETIC}check:${group.label}:${name}`; + nodes.push({ + id: nodeId, + data: { + title: name, + subtitle: gateText ?? checkRun.error ?? group.label, + tone: checkStatusTone(checkRun.status), + shape: "item", + badges: [ + checkRun.status ?? "unknown", + ...(checkRun.critical === true ? ["critical"] : []), + ...(step === undefined ? ["not registered"] : []), + ...durationBadge(checkRun.duration_s), + ], + detail: [ + { label: "status", value: checkRun.status ?? "unknown" }, + { label: "recorded version", value: checkRun.check_version ?? "unknown" }, + ...(step === undefined + ? [ + { + label: "current pipeline", + value: "does not register a step by this name any more", + }, + ] + : [{ label: "current version", value: step.version }]), + ...(gateText === null ? [] : [{ label: "accepts when", value: gateText }]), + ...(checkRun.error === null ? [] : [{ label: "error", value: checkRun.error }]), + ...measurementDetail(measurementsByCheck.get(name) ?? []), + ], + drillTo: null, + }, + }); + edges.push({ + id: `${anchorId}->${nodeId}`, + source: anchorId, + target: nodeId, + dashed: true, + label: null, + }); + } + } + + const staleCheckCount = groups[2]?.runs.length ?? 0; + return { + nodes, + edges, + emptyMessage: null, + notices: [ + ...(dossier.check_runs.length === 0 ? ["No checks were recorded for this episode."] : []), + ...(staleCheckCount > 0 + ? [ + staleCheckCount === 1 + ? "1 recorded check no longer exists in the current pipeline." + : `${staleCheckCount} recorded checks no longer exist in the current pipeline.`, + ] + : []), + ], + }; +} + +function measurementValueText(measurement: { + value_double: number | null; + value_text: string | null; + value_bool: boolean | null; +}): string { + if (measurement.value_double !== null) return String(measurement.value_double); + if (measurement.value_bool !== null) return String(measurement.value_bool); + return measurement.value_text ?? "null"; +} + +function measurementDetail(rendered: readonly string[]): DetailLine[] { + if (rendered.length === 0) return []; + const shown = rendered.slice(0, MAX_LISTED_MEASUREMENTS); + const elided = rendered.length - shown.length; + return [ + { + label: "measurements", + value: elided > 0 ? `${shown.join(", ")}, and ${elided} more` : shown.join(", "), + }, + ]; +} + +// --- shared helpers --------------------------------------------------------- + +function findStage(pipeline: PipelineGraph, stage: Stage): PipelineGraphStage | null { + return pipeline.stages.find((candidate) => candidate.stage === stage) ?? null; +} + +function findRunStage(run: RunGraph | null, stage: Stage): RunGraphStage | null { + return run?.stages.find((candidate) => candidate.stage === stage) ?? null; +} + +function unknownStage(stage: Stage): CanvasGraph { + return { + nodes: [], + edges: [], + emptyMessage: `This server's topology has no ${stage} stage.`, + notices: EMPTY_GRAPH_NOTICES, + }; +} + +function taskInstanceBadges(instance: RunTaskInstance | null): string[] { + if (instance === null) return []; + return [ + ...(instance.state === null ? [] : [instance.state]), + // A first attempt is not worth a pill; a retry is exactly what someone + // scanning the canvas is looking for. + ...(instance.try_number !== null && instance.try_number > 1 + ? [`try ${instance.try_number}`] + : []), + ...durationBadge(instance.duration_s), + ]; +} + +function taskInstanceDetail(instance: RunTaskInstance | null): DetailLine[] { + if (instance === null) return [{ label: "state", value: "no instance for this run" }]; + return [ + { label: "state", value: instance.state ?? "not scheduled yet" }, + ...(instance.queued_at === null ? [] : [{ label: "queued", value: instance.queued_at }]), + ...(instance.start_date === null ? [] : [{ label: "started", value: instance.start_date }]), + ...(instance.end_date === null ? [] : [{ label: "ended", value: instance.end_date }]), + ...(instance.duration_s === null + ? [] + : [{ label: "duration", value: formatDuration(instance.duration_s) }]), + ...(instance.try_number === null ? [] : [{ label: "try", value: String(instance.try_number) }]), + ]; +} + +function durationBadge(seconds: number | null): string[] { + return seconds === null ? [] : [formatDuration(seconds)]; +} + +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "unknown"; + if (seconds < 10) return `${seconds.toFixed(1)}s`; + if (seconds < 60) return `${Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +/** One column of a catalog row as display text, or null when absent or null. */ +function textField(row: Record, column: string): string | null { + const value = row[column]; + if (value === null || value === undefined || value === "") return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return null; +} diff --git a/ui/src/canvas/focus.ts b/ui/src/canvas/focus.ts new file mode 100644 index 0000000..3a6b68b --- /dev/null +++ b/ui/src/canvas/focus.ts @@ -0,0 +1,82 @@ +// Where the canvas is pointed. One value describes the whole screen, so +// navigation is "replace the focus", never "mutate five pieces of state", and +// the breadcrumb is derived from the focus rather than tracked beside it. + +import type { Stage } from "../api"; + +/** + * The five things the canvas can be drawing. + * + * Each level names its own parent, which is what lets the breadcrumb be a pure + * function of the focus. They are levels of a DRILL-DOWN, not layers drawn at + * once: entering one replaces the canvas. + * + * Two branches leave the run, because a run has two things worth following: + * + * run -> stage -> steps the orchestration, and the code inside it + * run -> episodes -> episode the data it produced + * + * The data branch hangs off the RUN and not off a stage, and that is not a + * simplification. The catalog's `episodes` view is one row per episode (latest + * append wins), so a full ingest leaves every episode's current row stamped + * with the LAST stage that recorded. Scoping this branch to one stage would + * therefore answer "0 episodes" for three stages out of four. + */ +export type CanvasFocus = + | { readonly level: "run" } + | { readonly level: "stage"; readonly stage: Stage } + | { readonly level: "steps"; readonly stage: Stage } + | { readonly level: "episodes" } + | { readonly level: "episode"; readonly episodeId: string }; + +export const RUN_FOCUS: CanvasFocus = { level: "run" }; +export const EPISODES_FOCUS: CanvasFocus = { level: "episodes" }; + +export type Breadcrumb = { + readonly label: string; + readonly focus: CanvasFocus; +}; + +/** + * How far to shorten an episode id for a label. Long enough to stay unique + * across a run's episodes, short enough to fit a crumb. + */ +const EPISODE_ID_LABEL_LENGTH = 12; + +export function shortEpisodeId(episodeId: string): string { + return episodeId.slice(0, EPISODE_ID_LABEL_LENGTH); +} + +/** The trail from the run down to this focus, this focus included. */ +export function breadcrumbs(focus: CanvasFocus): Breadcrumb[] { + const trail: Breadcrumb[] = [{ label: "Run", focus: RUN_FOCUS }]; + switch (focus.level) { + case "run": + return trail; + case "stage": + trail.push({ label: focus.stage, focus }); + return trail; + case "steps": + trail.push({ label: focus.stage, focus: { level: "stage", stage: focus.stage } }); + trail.push({ label: "steps", focus }); + return trail; + case "episodes": + trail.push({ label: "episodes", focus }); + return trail; + case "episode": + trail.push({ label: "episodes", focus: EPISODES_FOCUS }); + trail.push({ label: shortEpisodeId(focus.episodeId), focus }); + return trail; + } +} + +/** The stage a focus is scoped to, or null on the levels that are run-scoped. */ +export function focusedStage(focus: CanvasFocus): Stage | null { + return focus.level === "stage" || focus.level === "steps" ? focus.stage : null; +} + +/** The level this one drills back out to, or null at the top. */ +export function parentFocus(focus: CanvasFocus): CanvasFocus | null { + const trail = breadcrumbs(focus); + return trail.length > 1 ? (trail[trail.length - 2]?.focus ?? null) : null; +} diff --git a/ui/src/canvas/layout.test.ts b/ui/src/canvas/layout.test.ts new file mode 100644 index 0000000..f3d5be8 --- /dev/null +++ b/ui/src/canvas/layout.test.ts @@ -0,0 +1,82 @@ +// The layout is a thin call into dagre, so these tests cover only what this +// module itself decides: that every node gets its OWN position, that the +// direction reads left to right, and that an edge naming a node this level did +// not draw cannot make dagre invent one. + +import { describe, expect, it } from "vitest"; +import type { CanvasEdge, CanvasNode, NodeShape } from "./buildGraph"; +import { layoutGraph } from "./layout"; + +function node(id: string, shape: NodeShape = "task"): CanvasNode { + return { + id, + data: { + title: id, + subtitle: null, + tone: "muted", + shape, + badges: [], + detail: [], + drillTo: null, + }, + }; +} + +function edge(source: string, target: string): CanvasEdge { + return { id: `${source}->${target}`, source, target, dashed: false, label: null }; +} + +describe("layoutGraph", () => { + it("gives every node its own position", () => { + // The regression this exists for: dagre writes the computed x/y onto the + // very object it was handed as the node's label, so one shared size + // literal per shape put every node of that shape at one point and laid the + // whole graph on top of itself. + const chain = ["a", "b", "c", "d"].map((id) => node(id)); + const positioned = layoutGraph(chain, [edge("a", "b"), edge("b", "c"), edge("c", "d")]); + const distinct = new Set(positioned.map((laid) => `${laid.position.x},${laid.position.y}`)); + expect(distinct.size).toBe(chain.length); + }); + + it("lays a chain out left to right, in dependency order", () => { + const positioned = layoutGraph( + [node("first"), node("second"), node("third")], + [edge("first", "second"), edge("second", "third")], + ); + const xByNode = new Map(positioned.map((laid) => [laid.id, laid.position.x])); + const first = xByNode.get("first") ?? 0; + const second = xByNode.get("second") ?? 0; + const third = xByNode.get("third") ?? 0; + expect(first).toBeLessThan(second); + expect(second).toBeLessThan(third); + }); + + it("spreads a fan across one rank without stacking it", () => { + const fan = [node("plan"), node("batch0"), node("batch1"), node("batch2"), node("gate")]; + const positioned = layoutGraph(fan, [ + edge("plan", "batch0"), + edge("plan", "batch1"), + edge("plan", "batch2"), + edge("batch0", "gate"), + edge("batch1", "gate"), + edge("batch2", "gate"), + ]); + const batches = positioned.filter((laid) => laid.id.startsWith("batch")); + // Same rank, so one x; different rows, so three distinct y values. + expect(new Set(batches.map((laid) => laid.position.x)).size).toBe(1); + expect(new Set(batches.map((laid) => laid.position.y)).size).toBe(3); + }); + + it("drops an edge naming a node that was not drawn", () => { + // Otherwise dagre invents an empty node for the missing endpoint and the + // canvas grows a blank box nothing explains. + const positioned = layoutGraph([node("only")], [edge("only", "vanished")]); + expect(positioned.map((laid) => laid.id)).toEqual(["only"]); + }); + + it("carries each shape's own box size through", () => { + const positioned = layoutGraph([node("task"), node("note", "note")], []); + const sizeById = new Map(positioned.map((laid) => [laid.id, laid.width])); + expect(sizeById.get("note")).toBeLessThan(sizeById.get("task") ?? 0); + }); +}); diff --git a/ui/src/canvas/layout.ts b/ui/src/canvas/layout.ts new file mode 100644 index 0000000..63519cd --- /dev/null +++ b/ui/src/canvas/layout.ts @@ -0,0 +1,104 @@ +// Positions for a built graph. Left to right, one rank per dependency depth -- +// the reading order every workflow builder uses, so nobody has to learn this +// canvas before they can follow it. +// +// dagre is given fixed node sizes rather than measured ones. Measuring would +// mean rendering, measuring, then laying out, which flashes the graph at the +// wrong positions on every focus change; instead the CSS clamps content to +// these boxes, so what dagre is told is always the truth. + +import dagre from "@dagrejs/dagre"; +import type { CanvasEdge, CanvasNode, NodeShape } from "./buildGraph"; + +export interface PositionedNode extends CanvasNode { + readonly position: { readonly x: number; readonly y: number }; + readonly width: number; + readonly height: number; +} + +// Boxes and gaps are deliberately tight. A level is framed by fitting its whole +// graph on screen, so every pixel of node width and rank gap is paid for in +// zoom: at the widths a comfortable card would want, a six-rank chain fits only +// at a zoom where none of its text can be read. +const NODE_SIZE: Record = { + task: { width: 196, height: 110 }, + item: { width: 196, height: 110 }, + // Furniture is smaller on purpose: a barrier or an anchor should read as a + // marker between the real nodes, not as one of them. + note: { width: 168, height: 92 }, +}; + +/** + * One node's box, as a FRESH object every call. + * + * The copy is not defensive style, it is required. dagre stores the object it + * is handed as the node's label and writes the computed x/y onto that same + * object, so handing every task node one shared literal makes all of them + * share one position -- which lays the whole graph on top of itself. + */ +export function nodeSize(shape: NodeShape): { width: number; height: number } { + return { ...NODE_SIZE[shape] }; +} + +export type GraphBounds = { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +}; + +/** + * The box the laid-out graph occupies, or null for an empty graph. + * + * Computed here rather than asked of React Flow, because the positions are + * already known: framing the canvas off this needs no node to have been + * measured in the DOM first, so it works on the first paint of a level. + */ +export function graphBounds(nodes: readonly PositionedNode[]): GraphBounds | null { + if (nodes.length === 0) return null; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const node of nodes) { + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x + node.width); + maxY = Math.max(maxY, node.position.y + node.height); + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +export function layoutGraph( + nodes: readonly CanvasNode[], + edges: readonly CanvasEdge[], +): PositionedNode[] { + const graph = new dagre.graphlib.Graph(); + graph.setGraph({ rankdir: "LR", nodesep: 20, ranksep: 68, marginx: 16, marginy: 16 }); + graph.setDefaultEdgeLabel(() => ({})); + + for (const node of nodes) { + graph.setNode(node.id, nodeSize(node.data.shape)); + } + for (const edge of edges) { + // An edge naming a node this level did not draw would make dagre invent an + // empty one, so it is dropped instead. Levels that rewire a mapped task + // into a fan are exactly where such an edge could slip through. + if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) { + graph.setEdge(edge.source, edge.target); + } + } + dagre.layout(graph); + + return nodes.map((node) => { + const { width, height } = nodeSize(node.data.shape); + const laid = graph.node(node.id); + return { + ...node, + width, + height, + // dagre centres a node on its position; React Flow anchors at the corner. + position: { x: (laid?.x ?? 0) - width / 2, y: (laid?.y ?? 0) - height / 2 }, + }; + }); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..7b159c1 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,31 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +// React Flow ships its own stylesheet and it has to load before ours, which +// overrides parts of it. +import "@xyflow/react/dist/style.css"; +import "./styles.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // This server is on localhost or a private network, so a failed request + // is a real refusal (no runtime, no catalog) far more often than it is a + // blip. Retrying would only delay showing the reason. + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +const container = document.getElementById("root"); +if (container === null) throw new Error("index.html is missing its #root element"); + +createRoot(container).render( + + + + + , +); diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000..594b047 --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,482 @@ +/* + * One stylesheet. Both palettes are declared here under prefers-color-scheme, + * so there is no theme toggle, nothing stored, and no pre-paint script. + * + * Every colour is a token. The six tone tokens are the tone names from + * src/tones.ts, which is what lets that module decide MEANING while this one + * decides appearance. + */ + +:root { + color-scheme: light dark; + + --bg: #f7f7f8; + --bg-panel: #ffffff; + --bg-sunken: #eeeef0; + --line: #d8d8dd; + --line-strong: #b6b6bf; + --ink: #17171a; + --ink-soft: #55555f; + --ink-faint: #85858f; + + --ok: #1a7f4b; + --err: #b8321f; + --warn: #9a6206; + --run: #1f5fbf; + --info: #5b4bb8; + --muted: #85858f; + + --radius: 8px; + --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #16161a; + --bg-panel: #1e1e24; + --bg-sunken: #121216; + --line: #33333d; + --line-strong: #4a4a58; + --ink: #ececf0; + --ink-soft: #a8a8b4; + --ink-faint: #7b7b88; + + --ok: #4cc38a; + --err: #f26d5b; + --warn: #e0a458; + --run: #6ba4f8; + --info: #a394f5; + --muted: #7b7b88; + } +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--ink); + font-family: var(--font); + font-size: 14px; + line-height: 1.45; +} + +/* --- shell ---------------------------------------------------------------- */ + +.app { + display: grid; + grid-template-rows: auto 1fr; + height: 100dvh; +} + +.topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: var(--bg-panel); + border-bottom: 1px solid var(--line); +} + +.brand { + font-weight: 650; + letter-spacing: 0.01em; +} + +.topbar-spacer { + flex: 1; +} + +.topbar-meta { + color: var(--ink-faint); + font-family: var(--mono); + font-size: 12px; + max-width: 40ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.run-picker { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.run-picker-label { + color: var(--ink-faint); + font-size: 12px; +} + +.run-picker select { + background: var(--bg-sunken); + border: 1px solid var(--line); + border-radius: 6px; + color: var(--ink); + font-family: var(--mono); + font-size: 12px; + max-width: 46ch; + padding: 4px 6px; +} + +.body { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + min-height: 0; +} + +.stage-area { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; + min-width: 0; + padding: 12px 16px 16px; +} + +.inspector { + background: var(--bg-panel); + border-left: 1px solid var(--line); + overflow-y: auto; + padding: 14px 16px; +} + +/* --- breadcrumb and captions --------------------------------------------- */ + +.crumbs { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.crumb { + background: none; + border: 0; + border-radius: 5px; + color: var(--run); + cursor: pointer; + font: inherit; + padding: 2px 5px; +} + +.crumb:hover { + background: var(--bg-sunken); +} + +.crumb:disabled { + color: var(--ink); + cursor: default; + font-weight: 600; +} + +.crumb-sep { + color: var(--ink-faint); +} + +.caption { + color: var(--ink-soft); + margin: 0; +} + +.notice { + background: var(--bg-panel); + border: 1px solid var(--line); + border-left: 3px solid var(--warn); + border-radius: 4px; + color: var(--ink-soft); + margin: 0; + padding: 6px 10px; +} + +/* --- canvas -------------------------------------------------------------- */ + +.surface { + background: var(--bg-sunken); + border: 1px solid var(--line); + border-radius: var(--radius); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.surface-message { + align-items: center; + color: var(--ink-soft); + display: flex; + flex-direction: column; + gap: 6px; + justify-content: center; + padding: 24px; + text-align: center; +} + +.error-title { + color: var(--err); + font-weight: 600; + margin: 0; +} + +.error-detail { + font-family: var(--mono); + font-size: 12px; + margin: 0; + max-width: 70ch; +} + +/* React Flow paints its own surface; ours is the one behind it. */ +.react-flow { + background: transparent; +} + +.react-flow__edge-path { + stroke: var(--line-strong); + stroke-width: 1.5; +} + +.edge-dashed .react-flow__edge-path { + stroke-dasharray: 4 4; +} + +.react-flow__edge-text { + fill: var(--ink-faint); + font-size: 10px; +} + +/* React Flow paints a rect behind an edge label; without a fill matching the + canvas the label sits on top of whatever the edge crosses. */ +.react-flow__edge-textbg { + fill: var(--bg-sunken); +} + +.react-flow__handle { + background: var(--line-strong); + border: 0; + height: 5px; + min-height: 5px; + min-width: 5px; + width: 5px; +} + +/* Scoped through .react-flow__controls to outrank React Flow's own rule, which + hardcodes a near-white button and would stay light in the dark palette. */ +.react-flow__controls .react-flow__controls-button { + background: var(--bg-panel); + border-bottom: 1px solid var(--line); + fill: var(--ink-soft); +} + +.react-flow__controls .react-flow__controls-button:hover { + background: var(--bg-sunken); + fill: var(--ink); +} + +.react-flow__attribution { + background: transparent; +} + +.react-flow__attribution a { + color: var(--ink-faint); +} + +/* --- nodes --------------------------------------------------------------- */ + +.node { + background: var(--bg-panel); + border: 1px solid var(--line); + border-left: 3px solid var(--tone, var(--muted)); + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: 3px; + height: 100%; + overflow: hidden; + padding: 8px 10px; + width: 100%; +} + +.node[data-drillable="true"] { + cursor: pointer; +} + +.node[data-selected="true"] { + border-color: var(--line-strong); + box-shadow: 0 0 0 2px var(--tone, var(--muted)); +} + +.node-note { + background: var(--bg-sunken); + border-style: dashed; + border-left-style: solid; +} + +.node-title { + align-items: baseline; + display: flex; + gap: 6px; + justify-content: space-between; +} + +.node-title-text { + font-family: var(--mono); + font-size: 12px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.node-drill { + color: var(--ink-faint); + flex: none; + font-size: 15px; + line-height: 1; +} + +.node-subtitle { + color: var(--ink-soft); + /* Two lines, then elide: the box is a fixed size the layout was told about, + so text must never be what decides how tall a node is. */ + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + display: -webkit-box; + font-size: 11px; + line-height: 1.3; + overflow: hidden; +} + +.node-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: auto; + overflow: hidden; +} + +.badge { + background: var(--bg-sunken); + border: 1px solid var(--line); + border-radius: 999px; + color: var(--ink-soft); + font-size: 10px; + line-height: 1.5; + padding: 0 6px; + white-space: nowrap; +} + +/* --- tones --------------------------------------------------------------- */ + +/* One rule per tone, setting the token every tone-aware element reads. */ +.tone-ok { + --tone: var(--ok); +} +.tone-err { + --tone: var(--err); +} +.tone-warn { + --tone: var(--warn); +} +.tone-run { + --tone: var(--run); +} +.tone-info { + --tone: var(--info); +} +.tone-muted { + --tone: var(--muted); +} + +.chip { + background: var(--bg-sunken); + border: 1px solid var(--tone, var(--muted)); + border-radius: 999px; + color: var(--tone, var(--muted)); + display: inline-block; + font-size: 11px; + margin: 0; + padding: 1px 8px; +} + +/* --- inspector ----------------------------------------------------------- */ + +.inspector-empty { + color: var(--ink-soft); +} + +.inspector-hint { + color: var(--ink-faint); + font-size: 12px; +} + +.inspector-title { + border-left: 3px solid var(--tone, var(--muted)); + font-family: var(--mono); + font-size: 14px; + margin: 0 0 8px; + overflow-wrap: anywhere; + padding-left: 8px; +} + +.inspector-subtitle { + color: var(--ink-soft); + margin: 8px 0; +} + +.drill-button { + background: var(--bg-sunken); + border: 1px solid var(--line-strong); + border-radius: 6px; + color: var(--ink); + cursor: pointer; + font: inherit; + margin: 8px 0; + padding: 4px 10px; +} + +.drill-button:hover { + border-color: var(--run); + color: var(--run); +} + +.inspector-detail { + display: flex; + flex-direction: column; + gap: 8px; + margin: 12px 0 0; +} + +.detail-line dt { + color: var(--ink-faint); + font-size: 11px; + text-transform: lowercase; +} + +.detail-line dd { + font-family: var(--mono); + font-size: 12px; + margin: 1px 0 0; + overflow-wrap: anywhere; +} + +@media (max-width: 900px) { + .body { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + } + + .inspector { + border-left: 0; + border-top: 1px solid var(--line); + max-height: 40dvh; + } +} diff --git a/ui/src/tones.ts b/ui/src/tones.ts new file mode 100644 index 0000000..2af1ae2 --- /dev/null +++ b/ui/src/tones.ts @@ -0,0 +1,76 @@ +// The one owner of "what colour does this outcome read as" for the whole UI. +// Two separate vocabularies meet here and must not be confused for each other: +// Airflow's task/run states, and hflow's own recorded check statuses. Every +// node fill and chip reads its tone through one of these two functions, so the +// canvas and the inspector can never drift apart. + +/** + * Six tones, one per thing an outcome can be saying. + * + * They name MEANINGS, not colours: styles.css decides what each one looks + * like. "run" is work in flight; "info" is work that finished and offered no + * verdict to pass or fail. + */ +export type Tone = "ok" | "err" | "warn" | "run" | "info" | "muted"; + +/** + * One Airflow task or run state. + * + * An unrecognized state renders muted rather than failing: Airflow gains + * states across versions, and one this build has not heard of is not an error. + */ +export function airflowStateTone(state: string | null | undefined): Tone { + switch (state?.toLowerCase()) { + case "success": + return "ok"; + case "failed": + return "err"; + case "running": + case "queued": + case "scheduled": + return "run"; + // A deferred task released its worker slot and is WAITING on a trigger. It + // is healthy, so it reads like work in flight, never like a failure. + case "deferred": + return "run"; + case "upstream_failed": + case "up_for_retry": + case "up_for_reschedule": + case "restarting": + return "warn"; + // Muted, not a warning: a skipped stage is the NORMAL outcome of a gate + // whose profile does not enable it (metadata_backfill skips three of the + // four stages by design), so colouring it as trouble would cry wolf on + // every backfill. + case "skipped": + return "muted"; + default: + return "muted"; + } +} + +/** + * One recorded check status (``hflow.steps.CheckStatus``). + * + * "failed" and "error" share a tone deliberately. They are different facts -- a + * decided False verdict about the DATA versus a crash in the check itself -- + * but both mean "this check did not pass", and the distinction is carried in + * the node's own text rather than by inventing a colour for it. + */ +export function checkStatusTone(status: string | null | undefined): Tone { + switch (status?.toLowerCase()) { + case "passed": + return "ok"; + case "failed": + case "error": + return "err"; + // Ran and recorded evidence, but offered no verdict: not a pass, not a + // failure. Neither of those tones would be true. + case "measured": + return "info"; + case "skipped": + return "muted"; + default: + return "muted"; + } +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..f0b4034 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..37009e6 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// Dev-only proxy: `hflow serve` binds the JSON API on 127.0.0.1:4356 by +// default. `preview` gets the same one so the built bundle can be exercised +// against a running API. +const apiProxy = { "/api": "http://127.0.0.1:4356" }; + +export default defineConfig({ + plugins: [react()], + server: { proxy: apiProxy }, + preview: { proxy: apiProxy }, +});