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 }, +});