Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions docs/SERVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
*.local
99 changes: 99 additions & 0 deletions ui/README.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions ui/biome.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
37 changes: 37 additions & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--
Both palettes are declared in styles.css under prefers-color-scheme, so
there is no theme toggle, no stored preference, and no pre-paint script
to keep in sync with one. This tells the browser to paint its own chrome
(scrollbars, form controls) to match whichever the OS asked for.
-->
<meta name="color-scheme" content="light dark" />
<!--
Favicon: the Hebbian Robotics mark, as a data: URI so the tab icon costs
no request and the app stays fully offline.
docs/assets/hebbian-logo-on-black.svg is the source of truth for the
artwork, so re-cutting the logo means updating this copy too.

A favicon cannot inherit currentColor, so the fill is literal, and it has
to survive both a light and a dark tab strip. #808080 is the grey that
clears 3:1 against every strip a browser paints: 3.95:1 on white, 3.01:1
on Chrome's light strip (#dee1e6), 3.09:1 on Chrome's dark strip
(#323639), 3.59:1 on Firefox's (#2b2a33). Lighter greys buy dark-strip
contrast at the light strip's expense and drop under 3:1 there, so this
is the middle and not a rounding.
-->
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='1.79 -0.23 106.03 137.62'%3E%3Cg fill='%23808080'%3E%3Cpath d='m24.3 68v-61c-0.2-3.5-3.1-6.5-6.3-7-1.9-0.3-6.3-0.3-8.6 0-2.5 0.2-4 1-5.4 2.6-1.8 2.1-2.2 3.6-2.2 7.4v114.2 2.4c0.1 4.1 0.2 5.2 1.4 7.1s3 3.3 5.3 3.6 6.9-0.3 9.2-0.9c2.7-0.6 5.5-2.7 6.3-5.9 0.5-2.4 0.3-0.9 0.3-62.5z'/%3E%3Cpath d='m99.9 1.7c-1.7-0.1-5.3-0.1-7.5 0.2-3.1 0.5-5.9 3.1-6.3 6.3-0.2 1.7-1.2 59.2-1.2 59.8l0.1 58.9c0.1 2.6 0.1 4 1.3 5.9 1.3 2 3.2 3.3 5.6 3.7 2.3 0.3 6.5 0.4 9.1 0.1 2.8-0.4 5.7-2.5 6.5-5.6 0.4-1.6 0.3-4.4 0.3-6.5v-112.1c0-3.4-0.1-5.4-1.3-7-1.2-2-3.6-3.5-6.6-3.7z'/%3E%3Cpath d='m44 16.2c-0.6 1.6-0.6 3.5-0.6 6.8v15c0.1 4.4 0.1 6 1.6 8s3.1 3.4 5.5 3.7c1.8 0.3 6.4 0.4 8.5 0.1 3.3-0.4 6.2-3 6.7-6.2 0.2-1.5 0.2-2.4 0.2-6.1l-0.1-16.5c0-3.6-0.4-5.1-2.2-7.1-1.1-1.2-2.8-2.4-5.1-2.7-1.4-0.2-5.5-0.2-7.5 0-3.1 0.2-6 2.3-7 5z'/%3E%3Cpath d='m68.8 58.5c-1.7-0.3-3.8-0.4-6.2-0.4h-17.7c-1.3 0-3 0.1-4.1 0.3-2.8 1-5.2 3.6-5.5 6.7-0.2 1.7-0.2 5.9 0.1 8.4 0.5 2.9 2.4 4.9 5.4 6.2 1.4 0.2 3.2 0.4 4.2 0.4h19.6c1.7 0 2.3 0 4.5-0.3 2.7-0.8 5-3.3 5.5-6.3 0.2-1.9 0.1-6.9-0.4-9.5-0.8-3-3.2-4.8-5.4-5.5z'/%3E%3Cpath d='m59.4 88c-1.8-0.3-6.9-0.4-9.1 0-2.9 0.6-5.4 2.8-6.2 5.8-0.4 1.8-0.3 4.3-0.3 8.2v11.7c0 4.9 0 7.7 1.3 9.5 1.2 1.9 2.8 3.4 5.4 3.8 1.6 0.3 6.7 0.3 8.4 0 2.6-0.3 4.8-1.9 5.8-3.9 1.2-1.7 1.1-4.5 1.1-8.5v-15.2c0-2.5 0-4.8-0.5-6.1-0.9-2.8-3.4-4.9-5.9-5.3z'/%3E%3C/g%3E%3C/svg%3E"
/>
<title>HFlow</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading