feat: add NodePalette and PipelineCanvas components for enhanced flow node management

- Introduced NodePalette component to display draggable nodes categorized by type.
- Implemented PipelineCanvas component to manage the flow of nodes and connections.
- Integrated drag-and-drop functionality for adding nodes to the canvas.
- Created a catalog of node types with associated metadata for rendering in the palette.
- Established conversion functions between pipeline definitions and React Flow state.
- Added inspector for editing node properties and managing connections.
- Updated Next.js configuration for production and development environments.
- Removed obsolete embed.go file and added nginx configuration for serving the app.
- Updated package dependencies including @xyflow/react for enhanced functionality.
- Added TypeScript definitions for CSS imports to support styling in components.
This commit is contained in:
patel-lyzr
2026-05-13 22:15:08 +05:30
parent 2e94e6bdf6
commit 64a857d1b7
36 changed files with 2265 additions and 498 deletions
+30
View File
@@ -0,0 +1,30 @@
# air config — https://github.com/air-verse/air
# Rebuilds and restarts the Go server when *.go files change.
# UI dev runs separately via `make dev` (Next.js HMR on :3000, /api proxied to :8080).
root = "."
tmp_dir = "tmp"
[build]
bin = "tmp/flow"
cmd = "go build -o tmp/flow ./cmd/flow"
delay = 200
exclude_dir = ["bin", "tmp", "web", "node_modules", ".git", ".next", "out"]
exclude_regex = ["_test\\.go$"]
include_ext = ["go"]
args_bin = ["serve"]
kill_delay = "1s"
send_interrupt = true
stop_on_error = true
[log]
time = false
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true
+1
View File
@@ -7,6 +7,7 @@
# Local Go binaries
bin/
tmp/
flow
*.test
coverage.out
+1
View File
@@ -1,4 +1,5 @@
/bin/
/tmp/
/flow
/*.db
/*.db-shm
+2 -10
View File
@@ -1,21 +1,13 @@
# --- web build stage ---
FROM node:22-alpine AS web
WORKDIR /app
COPY web/package.json web/package-lock.json* ./web/
RUN cd web && (test -f package-lock.json && npm ci --no-fund --no-audit || npm install --no-fund --no-audit)
COPY web ./web
RUN cd web && npm run build
# API server only — no UI, no web build stage.
# The UI lives in its own container (web/Dockerfile) and proxies /api here.
# --- go build stage ---
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web /app/web/out ./web/out
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/flow ./cmd/flow
# --- runtime stage ---
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /out/flow /usr/local/bin/flow
+13 -4
View File
@@ -1,4 +1,4 @@
.PHONY: build run serve test vet tidy clean web web-dev dev
.PHONY: build run serve test vet tidy clean web web-dev dev watch
BINARY := bin/flow
@@ -15,14 +15,23 @@ web:
web-dev:
cd web && (test -d node_modules || npm install --no-fund --no-audit --loglevel=error) && npm run dev
# `make dev` runs the Vite dev server (auto-installs deps).
# In another terminal run `make serve` to start the Go API on :8080;
# the Vite proxy forwards /api/* requests to it.
# `make dev` runs the Next.js dev server (auto-installs deps).
# In another terminal run `make watch` to hot-reload the Go API on :8080;
# the Next dev rewrite forwards /api/* requests to it.
dev: web-dev
serve: build-go
./$(BINARY) serve
# Hot-reload the Go server with air. Re-run on every *.go change.
# First time: `go install github.com/air-verse/air@latest`
watch:
@command -v air >/dev/null 2>&1 || { \
echo "installing air..."; \
go install github.com/air-verse/air@latest; \
}
air
run: build
./$(BINARY)
+112
View File
@@ -0,0 +1,112 @@
# Langship
Langship is a framework-agnostic deployment, governance, and operations layer for agent applications. It supports LangChain/LangGraph and other agent frameworks (LlamaIndex, CrewAI, AutoGen, Pydantic AI, raw SDK agents, etc.) — not tied to any single framework.
## Three pillars
- **Deployment** — packaging, versioning, rollouts, env/secret management for agent apps
- **Governance** — policies, budgets, approvals, audit logs, tenant isolation, safety filters
- **Operations** — monitoring, replay/debug, incident response, cost & performance management
## Deployment targets
Langship is multi-runtime. Supported deployment targets include:
- **Kubernetes** (self-hosted / any cloud)
- **AWS Bedrock AgentCore Runtime**
- **GCP Vertex AI Agent Engine**
The deployment layer abstracts over these runtimes so the same agent app definition can ship to any of them. Governance and operations policies must apply uniformly across runtimes.
## Positioning
Closer to a "platform for agents" (Kubernetes/Datadog-style) than a single-framework tool like LangSmith or LangGraph Platform.
## Top-level scope — Project
Langship's top-level scoping primitive is a **Project**. A Project owns: users + roles, environments, workflows, secrets, deployment configs, audit log. Self-hosted Langship typically serves several teams, so Projects isolate one team/agent from another from day one (rather than retrofitting tenancy later). API paths are scoped: `/projects/:id/...`. Note: "Project" in this doc always refers to the Langship Project; cloud-provider projects (e.g., GCP project) are always qualified with the provider name.
## CI/CD — configurable workflow builder
CI/CD is **not** a fixed pipeline. It is a **configurable, drag-and-drop workflow builder** where pipelines are graphs of nodes (triggers, build, test, eval, policy, approval, deploy, promote, rollback). The visual canvas is the UI; YAML in git is the source of truth (GitOps).
- **Two modes** — replace CI entirely (Langship runs build/test/eval/deploy) or CD + release-gates only (external CI hands off an artifact, Langship picks up at eval/governance/deploy).
- **Role-based views** — agent devs, platform/DevOps, governance owners see different node palettes on the same underlying graph.
- **Environments are first-class** — dev, staging, prod each have their own pipeline. Per-env config (secrets, runtime, scaling) is separate from the graph. Different runtimes per env are expected (e.g., dev on K8s, prod on Vertex Agent Engine).
- **Promotion is gated and branching-strategy-driven** — evals must pass → approval gate (human, automated policy, or quorum) → `Promote` node executes per project's branching strategy (trunk-based, env-branches, release branches, or custom). Promotion and rollback are auditable events.
- **Governance is a node, not a wrapper** — policies/approvals/budget gates are first-class, visible, reorderable steps in the graph.
## Deployment model — self-hosted
Langship is **self-hosted by the customer**. The customer runs the whole stack (API server, Restate, Postgres, workers, secrets manager) in their own infrastructure. Distribution is via Helm chart / installer / Docker Compose for local dev.
**Why self-hosted:**
- Customer's cloud credentials, agent code, eval data, and audit logs never leave their network — strong fit for the governance-focused positioning
- Clear regulatory story for finance/healthcare/gov buyers who can't adopt hosted control planes
- Simpler security model: no cross-tenant credential storage, no proxy of LLM traffic
- Customer's compliance team monitors logs in systems they already operate
**Trade-offs accepted:**
- Higher friction to adopt vs. hosted SaaS — installer/upgrade UX matters more
- Support is harder — no production access by default; need good telemetry-with-consent + clear runbooks
- Distribution: ship a Helm chart as primary path; Docker Compose for local dev / small teams
**Hosted offering may come later** as a managed deployment of the same stack, but the product is designed self-hosted-first. CLI/UI/API contracts assume the server is something the customer operates.
## Server architecture — three layers
The "server" is actually three layers, all running in the customer's infrastructure:
1. **API / control plane** — REST or gRPC. CLI and UI call this. Handles auth, RBAC, workflow CRUD, run triggers, approvals, audit queries. Stateless app servers.
2. **Orchestration layer** — Restate (primary) cluster + worker pool. Workers execute node logic (build, eval, deploy, etc.). Long-lived and durable.
3. **Data layer** — MongoDB (runs, approvals, audit, users, projects, workflows index); S3-compatible object store (artifacts, large eval outputs, trace blobs); secrets manager (Vault or cloud-native KMS) for cloud credentials. Postgres runs alongside, but **only** as Restate's required persistence backend — never accessed by Langship app code.
Plus a **GitOps sync** component (initially inside the API, possibly its own service later) that watches git refs and triggers workflows on push/tag/merge events.
### Minimal v0 server
For the CLI-first vertical slice:
- One API server process, Postgres-backed
- One Restate (Restate Cloud is fine for prototyping; final product self-hosts Restate too)
- One worker process executing a few node types
- CLI talks to the API
- No UI yet
That's the smallest thing that closes the loop: `langship deploy` → API receives → Restate workflow runs → status updates → CLI shows result.
## User flow — two roles, two experiences
Langship has two distinct user journeys. The product must serve both well.
### Platform engineer — one-time project setup
Heavy, infrequent. Done once per project, occasionally revisited. Mixes CLI + UI.
1. **Environments** — define dev / staging / prod (and any custom envs like `eu-prod`, `preview`)
2. **Branching strategy** — trunk-based / env-branches / release-branches / custom
3. **CI/CD pipeline with stages** — the workflow graph (build → eval → approval → deploy → promote, etc.) per env
4. **Deployment configs** — per-env cloud credentials and runtime targets (e.g., K8s cluster X for dev, Bedrock AgentCore account Y for staging, Vertex Agent Engine project Z for prod)
Output: a configured project that agent developers consume.
### Agent developer — repeatable deploy loop
Light, frequent. Daily driver. CLI-first.
5. **Drop in agent repo** — link the repo to a Langship project (GitOps: Langship watches refs per env, not a one-time blob upload)
6. **Select** — pick project / pipeline / env target
7. **Deploy** — trigger the run; pipeline executes; agent ships
### Implications for product surface
- **CLI is the daily-use surface** for agent devs (steps 57) and the bootstrap surface for platform engineers (steps 14 initially).
- **UI is the visualization + governance surface** — workflow canvas, run history, approval inbox, audit log, release-flow view across envs. Comes after CLI proves the model.
- **Build CLI first.** A working CLI gives end-to-end usefulness sooner; UI built on top of unproven flows risks designing for the wrong thing.
- **Agent repo is git-based, not uploaded.** Langship references the repo by URL + ref; pipelines trigger on push/tag/merge events per env's branching strategy. Preserves traceability, reproducibility, and the GitOps story.
## Design principle
Core APIs and data models must stay framework-agnostic. The key abstraction is a common interface across frameworks (runs, traces via OpenTelemetry/OpenLLMetry) so governance and operations policies apply uniformly regardless of the underlying agent framework. Avoid LangChain-only assumptions in core abstractions.
Engine Restate) must stay wrapped behind Langship's own DSL — users never see the engine directly. Swapping later is possible but disruptive; pick deliberately.
+16 -3
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -16,7 +17,6 @@ import (
"github.com/lyzrai/flow/pkg/engine"
"github.com/lyzrai/flow/pkg/executors"
"github.com/lyzrai/flow/pkg/orchestrator"
"github.com/lyzrai/flow/web"
)
func main() {
@@ -52,6 +52,7 @@ usage:
env vars (for `+"`flow serve`"+`):
FLOW_ADDR HTTP listen address (default :8080)
FLOW_CORS_ORIGINS comma-separated allow-list (default *)
RESTATE_INGRESS_URL Restate ingress URL (default http://localhost:8081)
RESTATE_ADMIN_URL Restate admin URL (default http://localhost:9070)
RESTATE_SERVICE_ADDR Restate service-endpoint listen addr (default :9080)
@@ -131,13 +132,14 @@ func serve() int {
slog.Info("registered with restate", slog.String("deploy_uri", deployURI))
}()
// HTTP server (UI + API).
// HTTP API server. The UI runs in a separate process (nginx in prod,
// `next dev` locally) and proxies /api to here.
srv := &http.Server{
Addr: addr,
Handler: api.NewServer(api.ServerDeps{
Assets: web.Dist(),
Orchestrator: orch,
RestateIngressURL: orch.IngressURL(),
CORSOrigins: parseCSV(envOr("FLOW_CORS_ORIGINS", "*")),
}),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -179,3 +181,14 @@ func envOr(key, def string) string {
}
return def
}
func parseCSV(v string) []string {
out := []string{}
for _, p := range strings.Split(v, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
+23 -2
View File
@@ -1,4 +1,11 @@
services:
mongo:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
restate:
image: docker.io/restatedev/restate:latest
ports:
@@ -8,11 +15,25 @@ services:
flow:
build: .
ports:
- "8080:8080" # UI + API
- "9080:9080" # restate callback
- "8080:8080" # JSON API
- "9080:9080" # restate callback endpoint
environment:
- MONGO_URI=mongodb://mongo:27017
- MONGO_DB=flow
- FLOW_CORS_ORIGINS=http://localhost:3000,http://web:3000
- RESTATE_INGRESS_URL=http://restate:8080
- RESTATE_ADMIN_URL=http://restate:9070
- RESTATE_DEPLOYMENT_URI=http://flow:9080
depends_on:
- mongo
- restate
web:
build: ./web
ports:
- "3000:3000" # UI (open this in your browser)
depends_on:
- flow
volumes:
mongo-data:
+49
View File
@@ -0,0 +1,49 @@
{
"name": "hello",
"nodes": [
{
"id": "1",
"name": "Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"parameters": {},
"position": [0, 0]
},
{
"id": "2",
"name": "Set Greeting",
"type": "n8n-nodes-base.set",
"typeVersion": 3,
"parameters": {
"assignments": {
"assignments": [
{ "name": "greeting", "value": "hello, flow", "type": "string" },
{ "name": "version", "value": 1, "type": "number" }
]
}
},
"position": [200, 0]
},
{
"id": "3",
"name": "End",
"type": "n8n-nodes-base.noop",
"typeVersion": 1,
"parameters": {},
"position": [400, 0]
}
],
"connections": {
"Trigger": {
"main": [
[{ "node": "Set Greeting", "type": "main", "index": 0 }]
]
},
"Set Greeting": {
"main": [
[{ "node": "End", "type": "main", "index": 0 }]
]
}
},
"settings": {}
}
+9 -1
View File
@@ -16,13 +16,21 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+41
View File
@@ -27,6 +27,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
@@ -45,6 +47,17 @@ github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZB
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
@@ -53,8 +66,36 @@ go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgf
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+53 -88
View File
@@ -1,9 +1,8 @@
// Package api provides the HTTP server for flow.
//
// It serves:
// - /api/health — readiness probe
// - /api/workflows — workflow CRUD (in-memory placeholder until storage lands)
// - / — embedded SPA (any non-/api path returns index.html)
// It serves only the JSON API under /api/*. The UI is a separate process
// (see ./web — nginx in prod, `next dev` locally) that proxies /api to here.
// Anything outside /api returns 404.
//
// The router is intentionally std-library only at this stage; we'll lift in a
// proper router (chi or gin) when the API surface grows.
@@ -15,7 +14,6 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"strings"
@@ -38,19 +36,21 @@ type FlowSummary struct {
}
// ServerDeps groups the construction-time dependencies of the HTTP server.
// Assets is the embedded UI bundle; Orchestrator drives durable execution;
// RestateIngressURL is used to resolve Awakeables from the resume handler.
// Orchestrator drives durable execution; RestateIngressURL is used to
// resolve Awakeables from the resume handler. CORSOrigins lists allowed
// browser origins (use "*" to allow any — fine for dev).
type ServerDeps struct {
Assets fs.FS
Orchestrator orchestrator.Orchestrator
RestateIngressURL string
CORSOrigins []string
}
// Server is a thin HTTP server that bundles API + embedded SPA.
// Server is a thin JSON API server. It does not serve a frontend.
type Server struct {
mux *http.ServeMux
orch orchestrator.Orchestrator
restateIngres string
corsOrigins []string
mu sync.Mutex
flows map[string]storedFlow // in-memory placeholder; storage layer lands next
@@ -66,25 +66,32 @@ type storedFlow struct {
NodeCount int `json:"nodeCount"`
}
// NewServer constructs a Server with API routes installed and the SPA mounted
// at "/" using deps.Assets. deps.Orchestrator may be nil — execute routes will
// then return 503.
// NewServer constructs an API-only Server. deps.Orchestrator may be nil —
// execute routes will then return 503.
func NewServer(deps ServerDeps) *Server {
s := &Server{
mux: http.NewServeMux(),
orch: deps.Orchestrator,
restateIngres: deps.RestateIngressURL,
corsOrigins: deps.CORSOrigins,
flows: make(map[string]storedFlow),
}
s.routes(deps.Assets)
s.routes()
return s
}
// ServeHTTP implements http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
// ServeHTTP implements http.Handler. CORS is applied here so all routes
// (including OPTIONS preflight) get the headers.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.applyCORS(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
s.mux.ServeHTTP(w, r)
}
func (s *Server) routes(assets fs.FS) {
// API
func (s *Server) routes() {
s.mux.HandleFunc("GET /api/health", s.handleHealth)
s.mux.HandleFunc("GET /api/workflows", s.handleListFlows)
s.mux.HandleFunc("POST /api/workflows", s.handleCreateFlow)
@@ -95,8 +102,35 @@ func (s *Server) routes(assets fs.FS) {
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
s.mux.HandleFunc("POST /api/executions/{id}/resume", s.handleResumeExecution)
// SPA: any path not starting with /api falls through to the embedded bundle.
s.mux.Handle("/", spaHandler(assets))
// Anything not under /api/ is not our concern — the UI server handles it.
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, fmt.Errorf("no handler for %s", r.URL.Path))
})
}
// applyCORS sets the response headers needed for the UI process to call us
// from a different origin. In dev that's http://localhost:3000; in prod it's
// the nginx web container (same origin via proxy, but harmless to allow).
func (s *Server) applyCORS(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
return
}
allowed := false
for _, o := range s.corsOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
if !allowed {
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
w.Header().Set("Access-Control-Max-Age", "600")
}
// --- handlers -------------------------------------------------------------
@@ -386,75 +420,6 @@ func (s *Server) parseExecuteRequest(body []byte) (json.RawMessage, []models.Ite
return nil, nil, errors.New("either workflow or workflow_id is required")
}
// --- SPA -----------------------------------------------------------------
// spaHandler serves static assets from fsys; falls back to index.html for any
// non-asset GET so client-side routing works on refresh.
func spaHandler(fsys fs.FS) http.Handler {
if fsys == nil {
// Frontend not built; render a small notice instead of a blank page.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, errors.New("frontend not built"))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(noBundleHTML))
})
}
files := http.FS(fsys)
fileServer := http.FileServer(files)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, errors.New("not found"))
return
}
// If the requested asset exists, serve it. Otherwise fall back to index.html.
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
serveIndex(w, r, fsys)
return
}
if f, err := fsys.Open(path); err == nil {
_ = f.Close()
// Long cache for fingerprinted assets, no-cache for index.html.
if strings.HasPrefix(path, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
fileServer.ServeHTTP(w, r)
return
}
serveIndex(w, r, fsys)
})
}
func serveIndex(w http.ResponseWriter, _ *http.Request, fsys fs.FS) {
data, err := fs.ReadFile(fsys, "index.html")
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(data)
}
const noBundleHTML = `<!doctype html>
<html><head><title>flow</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.card{max-width:420px;padding:32px;border:1px solid #334155;border-radius:12px;background:#1e293b}
h1{margin:0 0 8px;font-size:18px}
code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:12px}
</style></head>
<body><div class="card">
<h1>flow — frontend not built</h1>
<p>Run <code>cd web && npm install && npm run build</code> and rebuild the binary to ship the UI.</p>
</div></body></html>`
// --- helpers --------------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v any) {
+4 -7
View File
@@ -330,18 +330,15 @@ func TestResume_unwiredIngress503(t *testing.T) {
}
}
func TestSPA_fallsBackToIndexForUnknownRoutes(t *testing.T) {
// Without any Assets, SPA handler returns the no-bundle notice.
func TestNonAPI_returns404(t *testing.T) {
// API server no longer hosts the SPA — the UI is a separate process.
srv := NewServer(ServerDeps{})
for _, path := range []string{"/", "/flows/abc", "/runs/123"} {
r := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("%s: got %d", path, w.Code)
}
if !strings.Contains(w.Body.String(), "html") {
t.Errorf("%s: expected html body", path)
if w.Code != http.StatusNotFound {
t.Errorf("%s: got %d, want 404", path, w.Code)
}
}
}
+386
View File
@@ -0,0 +1,386 @@
package storage
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// Mongo holds the connected client + database handle. Construct via NewMongo
// and Close on shutdown.
type Mongo struct {
client *mongo.Client
db *mongo.Database
}
// NewMongo dials Mongo with a 10s connect+ping timeout and ensures indexes
// exist. Returns an error if the URI is unreachable.
func NewMongo(ctx context.Context, uri, dbName string) (*Mongo, error) {
if uri == "" {
return nil, errors.New("MONGO_URI is required")
}
if dbName == "" {
dbName = "flow"
}
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
return nil, fmt.Errorf("mongo connect: %w", err)
}
if err := client.Ping(dialCtx, nil); err != nil {
_ = client.Disconnect(context.Background())
return nil, fmt.Errorf("mongo ping %q: %w", uri, err)
}
m := &Mongo{client: client, db: client.Database(dbName)}
if err := m.ensureIndexes(ctx); err != nil {
return nil, fmt.Errorf("ensure indexes: %w", err)
}
return m, nil
}
// Close disconnects the client. Safe to call multiple times.
func (m *Mongo) Close(ctx context.Context) error {
if m == nil || m.client == nil {
return nil
}
return m.client.Disconnect(ctx)
}
// Pipelines returns the PipelineStore backed by this Mongo connection.
func (m *Mongo) Pipelines() PipelineStore { return &mongoPipelines{coll: m.db.Collection("pipelines")} }
// Runs returns the RunStore backed by this Mongo connection.
func (m *Mongo) Runs() RunStore { return &mongoRuns{coll: m.db.Collection("runs")} }
func (m *Mongo) ensureIndexes(ctx context.Context) error {
if _, err := m.db.Collection("pipelines").Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "updated_at", Value: -1}}},
}); err != nil {
return fmt.Errorf("pipelines indexes: %w", err)
}
if _, err := m.db.Collection("runs").Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "pipeline_id", Value: 1}, {Key: "started_at", Value: -1}}},
{Keys: bson.D{{Key: "started_at", Value: -1}}},
}); err != nil {
return fmt.Errorf("runs indexes: %w", err)
}
return nil
}
// --- pipelines ------------------------------------------------------------
type mongoPipelines struct{ coll *mongo.Collection }
// pipelineDoc swaps json.RawMessage for bson.Raw so the n8n definition stays
// queryable and round-trips cleanly through Mongo.
type pipelineDoc struct {
ID string `bson:"_id"`
Name string `bson:"name"`
Definition bson.Raw `bson:"definition"`
NodeCount int `bson:"node_count"`
Status string `bson:"status,omitempty"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
func (s *mongoPipelines) Create(ctx context.Context, p *Pipeline) error {
def, err := jsonToBSON(p.Definition)
if err != nil {
return err
}
_, err = s.coll.InsertOne(ctx, pipelineDoc{
ID: p.ID,
Name: p.Name,
Definition: def,
NodeCount: p.NodeCount,
Status: p.Status,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
})
return err
}
func (s *mongoPipelines) Get(ctx context.Context, id string) (*Pipeline, error) {
var doc pipelineDoc
if err := s.coll.FindOne(ctx, bson.M{"_id": id}).Decode(&doc); err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNotFound
}
return nil, err
}
return docToPipeline(&doc)
}
func (s *mongoPipelines) Update(ctx context.Context, p *Pipeline) error {
set := bson.M{
"name": p.Name,
"node_count": p.NodeCount,
"updated_at": p.UpdatedAt,
}
if p.Status != "" {
set["status"] = p.Status
}
if len(p.Definition) > 0 {
def, err := jsonToBSON(p.Definition)
if err != nil {
return err
}
set["definition"] = def
}
res, err := s.coll.UpdateByID(ctx, p.ID, bson.M{"$set": set})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrNotFound
}
return nil
}
func (s *mongoPipelines) Delete(ctx context.Context, id string) error {
res, err := s.coll.DeleteOne(ctx, bson.M{"_id": id})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrNotFound
}
return nil
}
func (s *mongoPipelines) List(ctx context.Context) ([]*Pipeline, error) {
cur, err := s.coll.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var docs []pipelineDoc
if err := cur.All(ctx, &docs); err != nil {
return nil, err
}
out := make([]*Pipeline, 0, len(docs))
for i := range docs {
p, err := docToPipeline(&docs[i])
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, nil
}
func docToPipeline(d *pipelineDoc) (*Pipeline, error) {
def, err := bsonToJSON(d.Definition)
if err != nil {
return nil, err
}
return &Pipeline{
ID: d.ID,
Name: d.Name,
Definition: def,
NodeCount: d.NodeCount,
Status: d.Status,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}, nil
}
// --- runs ----------------------------------------------------------------
type mongoRuns struct{ coll *mongo.Collection }
type runDoc struct {
ID string `bson:"_id"`
PipelineID string `bson:"pipeline_id"`
PipelineName string `bson:"pipeline_name,omitempty"`
Status string `bson:"status"`
StartedAt time.Time `bson:"started_at"`
FinishedAt *time.Time `bson:"finished_at,omitempty"`
TriggerData bson.Raw `bson:"trigger_data,omitempty"`
Outputs bson.Raw `bson:"outputs,omitempty"`
NodeOutputs bson.Raw `bson:"node_outputs,omitempty"`
Errors []string `bson:"errors,omitempty"`
}
func (s *mongoRuns) Insert(ctx context.Context, r *Run) error {
td, err := jsonToBSON(r.TriggerData)
if err != nil {
return err
}
_, err = s.coll.InsertOne(ctx, runDoc{
ID: r.ID,
PipelineID: r.PipelineID,
PipelineName: r.PipelineName,
Status: r.Status,
StartedAt: r.StartedAt,
TriggerData: td,
})
return err
}
func (s *mongoRuns) UpdateStatus(ctx context.Context, id, status string) error {
_, err := s.coll.UpdateByID(ctx, id, bson.M{"$set": bson.M{"status": status}})
return err
}
func (s *mongoRuns) Complete(ctx context.Context, id, status string, outputs, nodeOutputs json.RawMessage, errMsg string) error {
out, err := jsonToBSON(outputs)
if err != nil {
return err
}
nodeOut, err := jsonToBSON(nodeOutputs)
if err != nil {
return err
}
now := time.Now().UTC()
set := bson.M{
"status": status,
"finished_at": now,
}
if out != nil {
set["outputs"] = out
}
if nodeOut != nil {
set["node_outputs"] = nodeOut
}
if errMsg != "" {
set["errors"] = []string{errMsg}
}
_, err = s.coll.UpdateByID(ctx, id, bson.M{"$set": set})
return err
}
func (s *mongoRuns) Get(ctx context.Context, id string) (*Run, error) {
var d runDoc
if err := s.coll.FindOne(ctx, bson.M{"_id": id}).Decode(&d); err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNotFound
}
return nil, err
}
return docToRun(&d)
}
func (s *mongoRuns) ListByPipeline(ctx context.Context, pipelineID string, limit int) ([]*Run, error) {
if limit <= 0 {
limit = 50
}
cur, err := s.coll.Find(ctx, bson.M{"pipeline_id": pipelineID},
options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(int64(limit)))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
return collectRuns(ctx, cur)
}
func (s *mongoRuns) List(ctx context.Context, limit int) ([]*Run, error) {
if limit <= 0 {
limit = 50
}
cur, err := s.coll.Find(ctx, bson.M{},
options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(int64(limit)))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
return collectRuns(ctx, cur)
}
func collectRuns(ctx context.Context, cur *mongo.Cursor) ([]*Run, error) {
var docs []runDoc
if err := cur.All(ctx, &docs); err != nil {
return nil, err
}
out := make([]*Run, 0, len(docs))
for i := range docs {
r, err := docToRun(&docs[i])
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
func docToRun(d *runDoc) (*Run, error) {
td, err := bsonToJSON(d.TriggerData)
if err != nil {
return nil, err
}
out, err := bsonToJSON(d.Outputs)
if err != nil {
return nil, err
}
nodeOut, err := bsonToJSON(d.NodeOutputs)
if err != nil {
return nil, err
}
return &Run{
ID: d.ID,
PipelineID: d.PipelineID,
PipelineName: d.PipelineName,
Status: d.Status,
StartedAt: d.StartedAt,
FinishedAt: d.FinishedAt,
TriggerData: td,
Outputs: out,
NodeOutputs: nodeOut,
Errors: d.Errors,
}, nil
}
// --- json/bson conversions -----------------------------------------------
// jsonToBSON converts arbitrary JSON to a bson.Raw document so Mongo stores
// it as a real subdocument (queryable, indexable) rather than an opaque string.
// Returns nil for empty/null input.
func jsonToBSON(raw json.RawMessage) (bson.Raw, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return nil, fmt.Errorf("jsonToBSON: %w", err)
}
// Mongo top-level docs must be objects; wrap scalars/arrays under a key.
if _, isObj := v.(map[string]any); !isObj {
v = map[string]any{"_value": v}
}
b, err := bson.Marshal(v)
if err != nil {
return nil, fmt.Errorf("bson marshal: %w", err)
}
return b, nil
}
func bsonToJSON(r bson.Raw) (json.RawMessage, error) {
if len(r) == 0 {
return nil, nil
}
var v any
if err := bson.Unmarshal(r, &v); err != nil {
return nil, fmt.Errorf("bson unmarshal: %w", err)
}
if m, ok := v.(map[string]any); ok {
if inner, hasWrap := m["_value"]; hasWrap && len(m) == 1 {
v = inner
}
}
b, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("json marshal: %w", err)
}
return b, nil
}
+61
View File
@@ -0,0 +1,61 @@
// Package storage provides persistent stores for pipelines and execution
// history. The default backend is MongoDB; the interfaces stay narrow so an
// alternative backend (SQLite, Postgres) can be slotted in later.
package storage
import (
"context"
"encoding/json"
"errors"
"time"
)
// ErrNotFound is returned when a queried document does not exist. API
// handlers should map this to HTTP 404.
var ErrNotFound = errors.New("not found")
// Pipeline is the persisted shape of a pipeline (formerly "flow") that the
// API stores and returns to the UI. Definition is the n8n-format JSON.
type Pipeline struct {
ID string `json:"id" bson:"_id"`
Name string `json:"name" bson:"name"`
Definition json.RawMessage `json:"definition" bson:"definition"`
NodeCount int `json:"nodeCount" bson:"node_count"`
Status string `json:"status" bson:"status,omitempty"`
CreatedAt time.Time `json:"createdAt" bson:"created_at"`
UpdatedAt time.Time `json:"updatedAt" bson:"updated_at"`
}
// PipelineStore persists pipeline definitions.
type PipelineStore interface {
Create(ctx context.Context, p *Pipeline) error
Get(ctx context.Context, id string) (*Pipeline, error)
Update(ctx context.Context, p *Pipeline) error
Delete(ctx context.Context, id string) error
List(ctx context.Context) ([]*Pipeline, error)
}
// Run is a single execution attempt against a pipeline. Captured at submit
// time; status/outputs are filled in later when the orchestrator finishes.
type Run struct {
ID string `json:"id" bson:"_id"` // execution_id
PipelineID string `json:"pipelineId" bson:"pipeline_id"`
PipelineName string `json:"pipelineName" bson:"pipeline_name"`
Status string `json:"status" bson:"status"`
StartedAt time.Time `json:"startedAt" bson:"started_at"`
FinishedAt *time.Time `json:"finishedAt,omitempty" bson:"finished_at,omitempty"`
TriggerData json.RawMessage `json:"triggerData,omitempty" bson:"trigger_data,omitempty"`
Outputs json.RawMessage `json:"outputs,omitempty" bson:"outputs,omitempty"`
NodeOutputs json.RawMessage `json:"nodeOutputs,omitempty" bson:"node_outputs,omitempty"`
Errors []string `json:"errors,omitempty" bson:"errors,omitempty"`
}
// RunStore persists execution history.
type RunStore interface {
Insert(ctx context.Context, r *Run) error
UpdateStatus(ctx context.Context, id, status string) error
Complete(ctx context.Context, id, status string, outputs, nodeOutputs json.RawMessage, errMsg string) error
Get(ctx context.Context, id string) (*Run, error)
ListByPipeline(ctx context.Context, pipelineID string, limit int) ([]*Run, error)
List(ctx context.Context, limit int) ([]*Run, error)
}
+14
View File
@@ -0,0 +1,14 @@
# --- build stage ---
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN test -f package-lock.json && npm ci --no-fund --no-audit \
|| npm install --no-fund --no-audit
COPY . .
RUN npm run build
# --- runtime: nginx serving the static export, proxying /api to the flow API ---
FROM nginx:1.27-alpine
COPY --from=build /app/out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
+1 -1
View File
@@ -105,7 +105,7 @@ function ExecutionView() {
const terminal = ["success", "completed", "failed", "error"].includes(s.toLowerCase());
return (
<div className="space-y-6">
<div className="space-y-6 p-6">
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
+147 -90
View File
@@ -1,65 +1,71 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Save } from "lucide-react";
import { ArrowLeft, FileJson, LayoutGrid, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { PipelineCanvas } from "@/components/canvas/pipeline-canvas";
import { api } from "@/lib/api";
import type { PipelineDefinition } from "@/lib/pipeline-graph";
import Link from "next/link";
const sample = `{
"name": "Hello",
"nodes": [
const STARTER: PipelineDefinition = {
name: "",
nodes: [
{
"id": "1",
"name": "When clicked",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"parameters": {}
id: "1",
name: "Trigger",
type: "flow-nodes-base.trigger",
typeVersion: 1,
parameters: {},
position: [0, 0],
},
{
"id": "2",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [200, 0],
"parameters": { "values": { "string": [{ "name": "msg", "value": "hello" }] } }
}
],
"connections": {
"When clicked": { "main": [[{ "node": "Set", "type": "main", "index": 0 }]] }
}
}`;
connections: {},
};
export default function NewFlowPage() {
type Tab = "canvas" | "json";
export default function NewPipelinePage() {
const router = useRouter();
const [name, setName] = useState("");
const [definition, setDefinition] = useState(sample);
const [tab, setTab] = useState<Tab>("canvas");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const defRef = useRef<PipelineDefinition>(STARTER);
const [jsonDraft, setJsonDraft] = useState(JSON.stringify(STARTER, null, 2));
function switchTab(next: Tab) {
if (next === "json") {
setJsonDraft(JSON.stringify({ ...defRef.current, name }, null, 2));
setTab(next);
return;
}
try {
const parsed = JSON.parse(jsonDraft) as PipelineDefinition;
defRef.current = parsed;
if (parsed.name) setName(parsed.name);
setError(null);
} catch (e) {
setError(`JSON parse failed: ${(e as Error).message}`);
return;
}
setTab(next);
}
async function onSave() {
setSaving(true);
setError(null);
try {
let parsed: unknown;
try {
parsed = JSON.parse(definition);
} catch (e) {
throw new Error(`Definition must be valid JSON: ${(e as Error).message}`);
}
const { id } = await api.createFlow({ name, definition: parsed });
const toSave: PipelineDefinition =
tab === "json" ? JSON.parse(jsonDraft) : defRef.current;
const { id } = await api.createFlow({
name,
definition: { ...toSave, name },
});
router.push(`/flows/view/?id=${encodeURIComponent(id)}`);
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
@@ -69,59 +75,110 @@ export default function NewFlowPage() {
}
return (
<div className="mx-auto w-full max-w-3xl space-y-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">New flow</h1>
<p className="mt-1 text-sm text-muted-foreground">
Paste an n8n workflow export, or start from the sample below.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Definition</CardTitle>
<CardDescription>
n8n-format JSON. Drafts are validated leniently; strict validation runs on
execute.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My flow (optional — uses workflow.name if blank)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="def">Workflow JSON</Label>
<div className="flex h-screen flex-col">
<Toolbar
name={name}
onName={setName}
tab={tab}
onTab={switchTab}
onSave={onSave}
saving={saving}
title="New pipeline"
/>
{error && (
<div className="border-b border-destructive/40 bg-destructive/5 px-4 py-2 text-xs text-destructive">
{error}
</div>
)}
<div className="relative min-h-0 flex-1">
{tab === "canvas" ? (
<PipelineCanvas
initialValue={defRef.current}
pipelineId="new"
onChange={(d) => {
defRef.current = d;
}}
fullBleed
/>
) : (
<div className="h-full p-4">
<Textarea
id="def"
rows={20}
value={definition}
onChange={(e) => setDefinition(e.target.value)}
value={jsonDraft}
onChange={(e) => setJsonDraft(e.target.value)}
spellCheck={false}
className="text-xs"
className="h-full text-xs"
/>
</div>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<Link href="/">
<Button variant="ghost">Cancel</Button>
</Link>
<Button onClick={onSave} disabled={saving}>
<Save />
{saving ? "Saving…" : "Save flow"}
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
function Toolbar(props: {
name: string;
onName: (v: string) => void;
tab: Tab;
onTab: (t: Tab) => void;
onSave: () => void;
saving: boolean;
title: string;
}) {
return (
<div className="flex h-12 shrink-0 items-center gap-2 border-b bg-background/80 px-3 backdrop-blur">
<Button variant="ghost" size="icon" asChild aria-label="Back">
<Link href="/">
<ArrowLeft className="size-4" />
</Link>
</Button>
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{props.title}
</div>
<Input
value={props.name}
onChange={(e) => props.onName(e.target.value)}
placeholder="Pipeline name"
className="ml-1 h-8 max-w-[280px] text-sm"
/>
<div className="flex-1" />
<Tabs value={props.tab} onChange={props.onTab} />
<Button onClick={props.onSave} disabled={props.saving} size="sm">
<Save />
{props.saving ? "Saving…" : "Save"}
</Button>
</div>
);
}
function Tabs({ value, onChange }: { value: Tab; onChange: (next: Tab) => void }) {
return (
<div className="inline-flex rounded-md border bg-muted/30 p-0.5">
<button
type="button"
onClick={() => onChange("canvas")}
className={
"flex items-center gap-1 rounded-sm px-2 py-1 text-xs transition-colors " +
(value === "canvas"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
<LayoutGrid className="size-3.5" />
Canvas
</button>
<button
type="button"
onClick={() => onChange("json")}
className={
"flex items-center gap-1 rounded-sm px-2 py-1 text-xs transition-colors " +
(value === "json"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
<FileJson className="size-3.5" />
Raw JSON
</button>
</div>
);
}
+238 -112
View File
@@ -1,40 +1,53 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Play, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
ArrowLeft,
FileJson,
LayoutGrid,
Play,
Save,
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { PipelineCanvas } from "@/components/canvas/pipeline-canvas";
import { api, type StoredFlow } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import type { PipelineDefinition } from "@/lib/pipeline-graph";
export default function FlowDetailPage() {
type Tab = "canvas" | "json";
const EMPTY: PipelineDefinition = { nodes: [], connections: {} };
export default function PipelineDetailPage() {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading</div>}>
<FlowDetail />
<Suspense fallback={<div className="p-6 text-sm text-muted-foreground">Loading</div>}>
<PipelineDetail />
</Suspense>
);
}
function FlowDetail() {
function PipelineDetail() {
const router = useRouter();
const params = useSearchParams();
const id = params.get("id") ?? "";
const [flow, setFlow] = useState<StoredFlow | null>(null);
const [name, setName] = useState("");
const [definition, setDefinition] = useState("");
const [loaded, setLoaded] = useState(false);
const [nodeCount, setNodeCount] = useState(0);
const defRef = useRef<PipelineDefinition>(EMPTY);
const [jsonDraft, setJsonDraft] = useState("{}");
const [tab, setTab] = useState<Tab>("canvas");
const [showRun, setShowRun] = useState(false);
const [input, setInput] = useState("[{}]");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
@@ -43,14 +56,20 @@ function FlowDetail() {
async function load() {
if (!id) return;
setLoaded(false);
try {
const f = await api.getFlow(id);
const def = (f.definition as PipelineDefinition | null) ?? EMPTY;
setFlow(f);
setName(f.name);
setDefinition(JSON.stringify(f.definition, null, 2));
defRef.current = def;
setNodeCount(def.nodes?.length ?? 0);
setJsonDraft(JSON.stringify(def, null, 2));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
} finally {
setLoaded(true);
}
}
@@ -59,12 +78,32 @@ function FlowDetail() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
function switchTab(next: Tab) {
if (next === "json") {
setJsonDraft(JSON.stringify({ ...defRef.current, name }, null, 2));
setTab(next);
return;
}
try {
const parsed = JSON.parse(jsonDraft) as PipelineDefinition;
defRef.current = parsed;
setNodeCount(parsed.nodes?.length ?? 0);
if (parsed.name) setName(parsed.name);
setError(null);
} catch (e) {
setError(`JSON parse failed: ${(e as Error).message}`);
return;
}
setTab(next);
}
async function onSave() {
setSaving(true);
setError(null);
try {
const parsed = JSON.parse(definition);
await api.updateFlow(id, { name, definition: parsed });
const toSave: PipelineDefinition =
tab === "json" ? JSON.parse(jsonDraft) : defRef.current;
await api.updateFlow(id, { name, definition: { ...toSave, name } });
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
@@ -83,9 +122,14 @@ function FlowDetail() {
if (!Array.isArray(v)) throw new Error("Input must be a JSON array");
parsedInput = v;
}
const res = await api.executeWorkflow({ workflow_id: id, input: parsedInput });
const res = await api.executeWorkflow({
workflow_id: id,
input: parsedInput,
});
setLastExecId(res.execution_id);
router.push(`/executions/view/?id=${encodeURIComponent(res.execution_id)}`);
router.push(
`/executions/view/?id=${encodeURIComponent(res.execution_id)}`
);
} catch (e) {
setError(e instanceof Error ? e.message : "run failed");
} finally {
@@ -94,7 +138,7 @@ function FlowDetail() {
}
async function onDelete() {
if (!confirm("Delete this flow? This cannot be undone.")) return;
if (!confirm("Delete this pipeline? This cannot be undone.")) return;
try {
await api.deleteFlow(id);
router.push("/");
@@ -105,111 +149,193 @@ function FlowDetail() {
if (!id) {
return (
<div className="text-sm text-muted-foreground">
<div className="p-6 text-sm text-muted-foreground">
Missing <code>id</code> query param.
</div>
);
}
return (
<div className="space-y-6">
{flow && (
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<span className="font-mono">{flow.id}</span>
<span>·</span>
<span>updated {formatDate(flow.updatedAt)}</span>
<Badge variant="secondary">{flow.nodeCount} nodes</Badge>
</div>
)}
<div>
<h1 className="text-3xl font-semibold tracking-tight">
{name || "Untitled flow"}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Edit, save, and run this workflow against the orchestrator.
</p>
<div className="flex h-screen flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b bg-background/80 px-3 backdrop-blur">
<Button variant="ghost" size="icon" asChild aria-label="Back">
<Link href="/">
<ArrowLeft className="size-4" />
</Link>
</Button>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Pipeline name"
className="h-8 max-w-[280px] text-sm"
/>
<Badge variant="secondary" className="shrink-0">
{nodeCount} nodes
</Badge>
{flow && (
<span className="hidden truncate font-mono text-[10px] text-muted-foreground md:inline">
{flow.id}
</span>
)}
<div className="flex-1" />
<Tabs value={tab} onChange={switchTab} />
<Button variant="outline" size="sm" onClick={() => setShowRun((v) => !v)}>
<Play />
Run
</Button>
<Button onClick={onSave} disabled={saving} size="sm">
<Save />
{saving ? "Saving…" : "Save"}
</Button>
<Button
variant="ghost"
size="icon"
onClick={onDelete}
aria-label="Delete pipeline"
className="text-destructive hover:bg-destructive/10"
>
<Trash2 />
</Button>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
<div className="border-b border-destructive/40 bg-destructive/5 px-4 py-2 text-xs text-destructive">
{error}
</div>
)}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Definition</CardTitle>
<CardDescription>n8n-format JSON</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
<div className="relative min-h-0 flex-1">
{tab === "canvas" ? (
loaded ? (
<PipelineCanvas
initialValue={defRef.current}
pipelineId={id}
onChange={(d) => {
defRef.current = d;
setNodeCount(d.nodes?.length ?? 0);
}}
fullBleed
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading pipeline
</div>
<div className="space-y-2">
<Label htmlFor="def">Workflow JSON</Label>
<Textarea
id="def"
rows={24}
value={definition}
onChange={(e) => setDefinition(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={onDelete}>
<Trash2 />
Delete
</Button>
<Button onClick={onSave} disabled={saving}>
<Save />
{saving ? "Saving…" : "Save"}
</Button>
</div>
</CardContent>
</Card>
)
) : (
<div className="h-full p-4">
<Textarea
value={jsonDraft}
onChange={(e) => setJsonDraft(e.target.value)}
spellCheck={false}
className="h-full text-xs"
/>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Run</CardTitle>
<CardDescription>Submit to the orchestrator</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="input">Trigger input (JSON array)</Label>
<Textarea
id="input"
rows={8}
value={input}
onChange={(e) => setInput(e.target.value)}
spellCheck={false}
className="text-xs"
/>
<p className="text-xs text-muted-foreground">
e.g. <code className="font-mono">[{`{"foo":"bar"}`}]</code>
</p>
</div>
<Button className="w-full" onClick={onRun} disabled={running}>
<Play />
{running ? "Submitting…" : "Run flow"}
</Button>
{lastExecId && (
<div className="rounded-md border bg-muted/30 p-3 text-xs">
<div className="font-medium">Last execution</div>
<Link
href={`/executions/view/?id=${encodeURIComponent(lastExecId)}`}
className="font-mono text-primary hover:underline"
>
{lastExecId}
</Link>
</div>
)}
</CardContent>
</Card>
{showRun && (
<RunPanel
input={input}
onInput={setInput}
running={running}
onRun={onRun}
lastExecId={lastExecId}
onClose={() => setShowRun(false)}
/>
)}
</div>
</div>
);
}
function RunPanel(props: {
input: string;
onInput: (v: string) => void;
running: boolean;
onRun: () => void;
lastExecId: string | null;
onClose: () => void;
}) {
return (
<div className="absolute right-4 top-4 z-20 w-80 rounded-lg border bg-background p-4 shadow-xl">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold">Run pipeline</div>
<Button
variant="ghost"
size="icon"
onClick={props.onClose}
aria-label="Close"
>
<X className="size-4" />
</Button>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="input">Trigger input (JSON array)</Label>
<Textarea
id="input"
rows={6}
value={props.input}
onChange={(e) => props.onInput(e.target.value)}
spellCheck={false}
className="text-xs"
/>
<p className="text-[11px] text-muted-foreground">
e.g. <code className="font-mono">[{`{"foo":"bar"}`}]</code>
</p>
</div>
<Button
className="w-full"
onClick={props.onRun}
disabled={props.running}
>
<Play />
{props.running ? "Submitting…" : "Run"}
</Button>
{props.lastExecId && (
<div className="rounded-md border bg-muted/30 p-2 text-[11px]">
<div className="font-medium">Last execution</div>
<Link
href={`/executions/view/?id=${encodeURIComponent(props.lastExecId)}`}
className="font-mono text-primary hover:underline"
>
{props.lastExecId}
</Link>
</div>
)}
</div>
</div>
);
}
function Tabs({ value, onChange }: { value: Tab; onChange: (next: Tab) => void }) {
return (
<div className="inline-flex rounded-md border bg-muted/30 p-0.5">
<button
type="button"
onClick={() => onChange("canvas")}
className={
"flex items-center gap-1 rounded-sm px-2 py-1 text-xs transition-colors " +
(value === "canvas"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
<LayoutGrid className="size-3.5" />
Canvas
</button>
<button
type="button"
onClick={() => onChange("json")}
className={
"flex items-center gap-1 rounded-sm px-2 py-1 text-xs transition-colors " +
(value === "json"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
<FileJson className="size-3.5" />
Raw JSON
</button>
</div>
);
}
+2 -13
View File
@@ -2,13 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import { AppSidebar } from "@/components/app-sidebar";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Breadcrumbs } from "@/components/breadcrumbs";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
export const metadata: Metadata = {
title: "flow",
@@ -28,12 +22,7 @@ export default function RootLayout({
>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 border-b bg-background/80 px-4 backdrop-blur">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumbs />
</header>
<div className="flex flex-1 flex-col gap-4 p-6">{children}</div>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</SidebarInset>
</SidebarProvider>
</body>
+27 -22
View File
@@ -33,13 +33,14 @@ export default function DashboardPage() {
useEffect(() => {
load();
api.health()
api
.health()
.then(() => setHealth("ok"))
.catch(() => setHealth("down"));
}, []);
async function onDelete(id: string) {
if (!confirm("Delete this flow?")) return;
if (!confirm("Delete this pipeline?")) return;
try {
await api.deleteFlow(id);
await load();
@@ -49,12 +50,13 @@ export default function DashboardPage() {
}
return (
<div className="space-y-8">
<div className="space-y-8 p-6">
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Flows</h1>
<h1 className="text-3xl font-semibold tracking-tight">Pipelines</h1>
<p className="mt-1 text-sm text-muted-foreground">
Durable, n8n-compatible workflows. Paste exported JSON to import.
Durable, n8n-compatible pipelines. Build on the canvas or paste exported
JSON to import.
</p>
</div>
<div className="flex items-center gap-2">
@@ -74,12 +76,12 @@ export default function DashboardPage() {
<RefreshCw />
Refresh
</Button>
<Link href="/flows/new">
<Button size="sm">
<Button size="sm" asChild>
<Link href="/flows/new">
<Plus />
New flow
</Button>
</Link>
New pipeline
</Link>
</Button>
</div>
</div>
@@ -100,7 +102,9 @@ export default function DashboardPage() {
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate">{f.name || "Untitled flow"}</CardTitle>
<CardTitle className="truncate">
{f.name || "Untitled pipeline"}
</CardTitle>
<CardDescription className="mt-1 truncate font-mono text-[11px]">
{f.id}
</CardDescription>
@@ -111,23 +115,24 @@ export default function DashboardPage() {
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-3">
<span>
<span className="font-medium text-foreground">{f.nodeCount}</span> nodes
<span className="font-medium text-foreground">{f.nodeCount}</span>{" "}
nodes
</span>
<span>·</span>
<span>{formatDate(f.updatedAt)}</span>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Link href={`/flows/view/?id=${encodeURIComponent(f.id)}`}>
<Button size="sm" variant="ghost">
<Button size="sm" variant="ghost" asChild>
<Link href={`/flows/view/?id=${encodeURIComponent(f.id)}`}>
<Activity />
Open
</Button>
</Link>
</Link>
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => onDelete(f.id)}
aria-label="Delete flow"
aria-label="Delete pipeline"
>
<Trash2 />
</Button>
@@ -167,14 +172,14 @@ function EmptyState() {
<Plus className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">No flows yet</p>
<p className="text-sm font-medium">No pipelines yet</p>
<p className="text-sm text-muted-foreground">
Import an n8n workflow JSON to get started.
Build one on the canvas, or import an n8n workflow JSON.
</p>
</div>
<Link href="/flows/new">
<Button size="sm">Create your first flow</Button>
</Link>
<Button size="sm" asChild>
<Link href="/flows/new">Create your first pipeline</Link>
</Button>
</CardContent>
</Card>
);
+20 -3
View File
@@ -27,7 +27,9 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarSeparator,
useSidebar,
} from "@/components/ui/sidebar";
import { PanelLeftClose, PanelLeft } from "lucide-react";
type NavItem = {
title: string;
@@ -38,13 +40,13 @@ type NavItem = {
const primary: NavItem[] = [
{
title: "Flows",
title: "Pipelines",
href: "/",
icon: LayoutGrid,
match: (p) => p === "/" || p.startsWith("/flows/view"),
},
{
title: "New flow",
title: "New pipeline",
href: "/flows/new",
icon: PlusCircle,
match: (p) => p.startsWith("/flows/new"),
@@ -91,7 +93,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">flow</span>
<span className="text-xs text-muted-foreground">
pre-v0.1 · durable workflows
pre-v0.1 · durable pipelines
</span>
</div>
</Link>
@@ -169,8 +171,23 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<CollapseToggle />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
function CollapseToggle() {
const { toggleSidebar, state } = useSidebar();
const collapsed = state === "collapsed";
const Icon = collapsed ? PanelLeft : PanelLeftClose;
return (
<SidebarMenuButton onClick={toggleSidebar} className="text-muted-foreground">
<Icon className="size-4" />
<span>{collapsed ? "Expand" : "Collapse"}</span>
</SidebarMenuButton>
);
}
-88
View File
@@ -1,88 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
export function Breadcrumbs() {
return (
<Suspense fallback={<div className="h-4" />}>
<BreadcrumbsInner />
</Suspense>
);
}
function BreadcrumbsInner() {
const pathname = usePathname() || "/";
const params = useSearchParams();
const crumbs = derive(pathname, params);
return (
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((c, i) => {
const isLast = i === crumbs.length - 1;
return (
<span key={`${c.label}-${i}`} className="contents">
<BreadcrumbItem className={i === 0 ? "hidden md:block" : undefined}>
{isLast || !c.href ? (
<BreadcrumbPage>{c.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={c.href}>{c.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && (
<BreadcrumbSeparator
className={i === 0 ? "hidden md:block" : undefined}
/>
)}
</span>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}
function derive(
pathname: string,
params: URLSearchParams | null
): { label: string; href?: string }[] {
const id = params?.get("id");
if (pathname === "/" || pathname === "") {
return [{ label: "flow", href: "/" }, { label: "Flows" }];
}
if (pathname.startsWith("/flows/new")) {
return [
{ label: "flow", href: "/" },
{ label: "Flows", href: "/" },
{ label: "New" },
];
}
if (pathname.startsWith("/flows/view")) {
return [
{ label: "flow", href: "/" },
{ label: "Flows", href: "/" },
{ label: id ? `Flow ${id.slice(0, 8)}` : "Flow" },
];
}
if (pathname.startsWith("/executions/view")) {
return [
{ label: "flow", href: "/" },
{ label: "Executions" },
{ label: id ? `${id.slice(0, 12)}` : "Run" },
];
}
return [{ label: "flow", href: "/" }];
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { AlertTriangle } from "lucide-react";
import { lookup } from "@/lib/node-catalog";
import { cn } from "@/lib/utils";
import type { FlowNodeData } from "@/lib/pipeline-graph";
export function FlowNode({ data, selected }: NodeProps) {
const pn = (data as FlowNodeData).pipelineNode;
const entry = lookup(pn.type);
const Icon = entry?.icon ?? AlertTriangle;
const outputs = entry?.outputs ?? 1;
const isTrigger = pn.type === "flow-nodes-base.trigger";
return (
<div
className={cn(
"min-w-[200px] rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow",
selected ? "ring-2 ring-primary shadow-md" : "hover:shadow-md",
!entry && "border-destructive/60"
)}
>
<div
className={cn(
"flex items-center gap-2 rounded-t-lg px-3 py-2 text-xs font-medium text-white",
entry?.color ?? "bg-destructive"
)}
>
<Icon className="size-3.5" />
<span className="truncate">{entry?.label ?? "Unsupported"}</span>
</div>
<div className="px-3 py-2">
<div className="truncate text-sm font-medium">{pn.name}</div>
<div className="truncate text-[11px] text-muted-foreground">{pn.type}</div>
</div>
{/* Input handle: triggers have no inputs */}
{!isTrigger && (
<Handle
type="target"
position={Position.Left}
id="i-0"
className="!h-2.5 !w-2.5 !border-2 !border-background !bg-muted-foreground"
/>
)}
{/* Output handle(s) */}
{Array.from({ length: outputs }).map((_, i) => {
const top = outputs === 1 ? "50%" : `${((i + 1) / (outputs + 1)) * 100}%`;
return (
<Handle
key={i}
type="source"
position={Position.Right}
id={`o-${i}`}
style={{ top }}
className="!h-2.5 !w-2.5 !border-2 !border-background !bg-primary"
/>
);
})}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { lookup } from "@/lib/node-catalog";
import type { PipelineNode } from "@/lib/pipeline-graph";
interface InspectorProps {
node: PipelineNode | null;
onChange: (next: PipelineNode) => void;
onDelete: (id: string) => void;
onClose: () => void;
}
export function Inspector({ node, onChange, onDelete, onClose }: InspectorProps) {
const [name, setName] = useState("");
const [paramsText, setParamsText] = useState("{}");
const [paramsErr, setParamsErr] = useState<string | null>(null);
useEffect(() => {
if (!node) return;
setName(node.name);
setParamsText(JSON.stringify(node.parameters ?? {}, null, 2));
setParamsErr(null);
}, [node?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!node) {
return (
<aside className="w-80 shrink-0 border-l bg-muted/20 p-4 text-sm text-muted-foreground">
Select a node to edit its parameters.
</aside>
);
}
const entry = lookup(node.type);
function commitName(next: string) {
if (!node) return;
onChange({ ...node, name: next });
}
function commitParams(text: string) {
if (!node) return;
try {
const parsed = JSON.parse(text || "{}");
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("parameters must be a JSON object");
}
setParamsErr(null);
onChange({ ...node, parameters: parsed as Record<string, unknown> });
} catch (e) {
setParamsErr(e instanceof Error ? e.message : "invalid JSON");
}
}
return (
<aside className="flex w-80 shrink-0 flex-col border-l bg-muted/20">
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{node.name}</div>
<div className="truncate text-[11px] text-muted-foreground">{node.type}</div>
</div>
<div className="flex items-center gap-1">
<Badge variant={entry ? "secondary" : "destructive"}>
{entry ? "supported" : "unsupported"}
</Badge>
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close">
×
</Button>
</div>
</div>
<div className="space-y-4 overflow-y-auto p-3">
<div className="space-y-1.5">
<Label htmlFor="node-name">Name</Label>
<Input
id="node-name"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => name !== node.name && commitName(name)}
/>
<p className="text-[11px] text-muted-foreground">
Names are used as references in <code>{`{{ $('Name').json.x }}`}</code>{" "}
expressions.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="node-params">Parameters (JSON)</Label>
<Textarea
id="node-params"
value={paramsText}
onChange={(e) => setParamsText(e.target.value)}
onBlur={() => commitParams(paramsText)}
rows={14}
spellCheck={false}
className="text-xs"
/>
{paramsErr && (
<p className="text-[11px] text-destructive">{paramsErr}</p>
)}
</div>
{entry && (
<div className="rounded-md border bg-background p-2 text-[11px] text-muted-foreground">
{entry.description}
</div>
)}
<Button
variant="outline"
className="w-full text-destructive hover:bg-destructive/10"
onClick={() => onDelete(node.id)}
>
<Trash2 />
Delete node
</Button>
</div>
</aside>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { CATALOG, GROUP_LABELS, type CatalogEntry } from "@/lib/node-catalog";
import { cn } from "@/lib/utils";
import { GripVertical } from "lucide-react";
const DRAG_TYPE = "application/x-flow-node";
export function NodePalette() {
const groups = CATALOG.reduce<Record<string, CatalogEntry[]>>((acc, c) => {
(acc[c.group] ??= []).push(c);
return acc;
}, {});
return (
<aside className="w-60 shrink-0 overflow-y-auto border-r bg-muted/20 p-3">
<div className="mb-3">
<div className="text-sm font-semibold">Nodes</div>
<p className="text-[11px] text-muted-foreground">
Drag onto the canvas to add.
</p>
</div>
<div className="space-y-4">
{Object.entries(groups).map(([group, items]) => (
<div key={group}>
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{GROUP_LABELS[group as CatalogEntry["group"]] ?? group}
</div>
<div className="space-y-1">
{items.map((item) => (
<PaletteItem key={item.type + item.label} entry={item} />
))}
</div>
</div>
))}
</div>
</aside>
);
}
function PaletteItem({ entry }: { entry: CatalogEntry }) {
const Icon = entry.icon;
return (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData(DRAG_TYPE, entry.type);
e.dataTransfer.effectAllowed = "move";
}}
title={entry.description}
className="group flex cursor-grab items-center gap-2 rounded-md border bg-background px-2 py-1.5 text-sm shadow-sm transition-colors hover:bg-accent active:cursor-grabbing"
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded text-white",
entry.color
)}
>
<Icon className="size-3.5" />
</span>
<span className="truncate">{entry.label}</span>
<GripVertical className="ml-auto size-3.5 opacity-30 group-hover:opacity-60" />
</div>
);
}
export const PALETTE_DRAG_TYPE = DRAG_TYPE;
+270
View File
@@ -0,0 +1,270 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Background,
Controls,
MiniMap,
ReactFlow,
ReactFlowProvider,
addEdge,
useEdgesState,
useNodesState,
useReactFlow,
type Connection,
type Node,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { lookup, uniqueName } from "@/lib/node-catalog";
import {
fromReactFlow,
toReactFlow,
type FlowNodeData,
type PipelineDefinition,
type PipelineNode,
} from "@/lib/pipeline-graph";
import { FlowNode } from "./flow-node";
import { Inspector } from "./inspector";
import { NodePalette, PALETTE_DRAG_TYPE } from "./node-palette";
const NODE_TYPES = { flowNode: FlowNode };
interface PipelineCanvasProps {
/** Initial pipeline definition. Read once per `pipelineId` the canvas owns
* graph state after that. Parent reads back via `onChange`. */
initialValue: PipelineDefinition | null;
/** Stable identity for the loaded pipeline (URL id, "new"). When this
* changes the canvas remounts via React's `key` to reload cleanly. */
pipelineId: string;
/** Called when the user makes an edit. Debounced + ref-stable internally. */
onChange?: (def: PipelineDefinition) => void;
/** Read-only mode disables all editing affordances. */
readOnly?: boolean;
/** Full-bleed: no border / rounded corners; fills parent instead of using
* the legacy fixed height. Use when the page wraps the canvas in its own
* layout (e.g. flow editor pages). */
fullBleed?: boolean;
}
// Public component: keys on pipelineId so a fresh inner instance mounts when
// the user navigates between pipelines. This is the simplest, bulletproof way
// to handle "load a different pipeline" without a controlled-prop reload loop.
export function PipelineCanvas(props: PipelineCanvasProps) {
return (
<ReactFlowProvider key={props.pipelineId}>
<CanvasInner {...props} />
</ReactFlowProvider>
);
}
function CanvasInner({
initialValue,
onChange,
readOnly,
fullBleed,
}: PipelineCanvasProps) {
// Compute initial RF state once. The canvas owns it from here on.
const initial = useMemo(() => toReactFlow(initialValue ?? null), []);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: load-once
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
const [selectedId, setSelectedId] = useState<string | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const { screenToFlowPosition } = useReactFlow();
// Stable refs so emit doesn't churn.
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const baseRef = useRef({ name: initialValue?.name, settings: initialValue?.settings });
// Emit changes upward, but **outside** the render cycle and debounced so a
// burst of internal RF state updates collapses into one notification.
const emitTimer = useRef<number | null>(null);
useEffect(() => {
if (emitTimer.current !== null) {
window.clearTimeout(emitTimer.current);
}
emitTimer.current = window.setTimeout(() => {
const fn = onChangeRef.current;
if (!fn) return;
fn(fromReactFlow(nodes, edges, baseRef.current));
emitTimer.current = null;
}, 100);
return () => {
if (emitTimer.current !== null) {
window.clearTimeout(emitTimer.current);
emitTimer.current = null;
}
};
}, [nodes, edges]);
const onConnect = useCallback(
(conn: Connection) => {
if (readOnly) return;
if (!conn.source || !conn.target || conn.source === conn.target) return;
setEdges((eds) =>
addEdge(
{
...conn,
id: `e:${conn.source}:${conn.sourceHandle ?? "o-0"}->${conn.target}:${conn.targetHandle ?? "i-0"}`,
},
eds
)
);
},
[readOnly, setEdges]
);
// --- drop from palette ---------------------------------------------------
const onDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes(PALETTE_DRAG_TYPE)) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
}, []);
const onDrop = useCallback(
(e: React.DragEvent) => {
if (readOnly) return;
const type = e.dataTransfer.getData(PALETTE_DRAG_TYPE);
if (!type) return;
e.preventDefault();
const entry = lookup(type);
if (!entry) return;
const position = screenToFlowPosition({ x: e.clientX, y: e.clientY });
setNodes((ns) => {
const existing = new Set<string>();
ns.forEach((n) => existing.add(n.id));
const name = uniqueName(entry.label, existing);
const pn: PipelineNode = {
id: cryptoId(),
name,
type: entry.type,
typeVersion: 1,
parameters: structuredClone(entry.defaults),
...(entry.settings ? { settings: structuredClone(entry.settings) } : {}),
position: [Math.round(position.x), Math.round(position.y)],
};
const rfNode: Node<FlowNodeData> = {
id: name,
type: "flowNode",
position,
data: { pipelineNode: pn },
};
// Defer the selection state update so we don't setState during another
// setState (React 18+ batches but we want to be explicit).
queueMicrotask(() => setSelectedId(name));
return [...ns, rfNode];
});
},
[readOnly, screenToFlowPosition, setNodes]
);
// --- selection / inspector ----------------------------------------------
const selectedPipelineNode: PipelineNode | null = useMemo(() => {
if (!selectedId) return null;
const n = nodes.find((nn) => nn.id === selectedId);
return (n?.data as FlowNodeData | undefined)?.pipelineNode ?? null;
}, [nodes, selectedId]);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedId(node.id);
}, []);
const onPaneClick = useCallback(() => setSelectedId(null), []);
const onInspectorChange = useCallback(
(next: PipelineNode) => {
const oldName = selectedPipelineNode?.name;
setNodes((ns) =>
ns.map((rn) => {
if (rn.id !== selectedId) return rn;
return { ...rn, id: next.name, data: { pipelineNode: next } };
})
);
if (oldName && next.name !== oldName) {
setEdges((es) =>
es.map((e) => ({
...e,
source: e.source === oldName ? next.name : e.source,
target: e.target === oldName ? next.name : e.target,
}))
);
setSelectedId(next.name);
}
},
[selectedId, selectedPipelineNode, setEdges, setNodes]
);
const onInspectorDelete = useCallback(
(id: string) => {
setNodes((ns) => ns.filter((n) => n.id !== id));
setEdges((es) => es.filter((e) => e.source !== id && e.target !== id));
setSelectedId(null);
},
[setEdges, setNodes]
);
return (
<div
className={
fullBleed
? "absolute inset-0 flex overflow-hidden bg-background"
: "flex h-[calc(100vh-260px)] min-h-[480px] overflow-hidden rounded-lg border bg-background"
}
>
{!readOnly && <NodePalette />}
<div
ref={wrapperRef}
className="relative flex-1"
onDragOver={onDragOver}
onDrop={onDrop}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: 0.2 }}
deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
elementsSelectable
proOptions={{ hideAttribution: true }}
>
<Background gap={16} />
<Controls position="bottom-left" />
<MiniMap pannable zoomable position="bottom-right" />
</ReactFlow>
</div>
{!readOnly && (
<Inspector
node={selectedPipelineNode}
onChange={onInspectorChange}
onDelete={onInspectorDelete}
onClose={() => setSelectedId(null)}
/>
)}
</div>
);
}
function cryptoId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return Math.random().toString(36).slice(2, 10);
}
-49
View File
@@ -1,49 +0,0 @@
// Package web ships the embedded SPA bundle that the flow HTTP server serves.
//
// The Next.js app under this directory builds a static export into ./out
// (Next's default for `output: "export"`). At Go build time we embed the
// contents of ./out as an io/fs.FS via Dist().
//
// To compile this package the out/ directory must exist and contain at least
// one file. Run `cd web && npm install && npm run build` before `go build`.
// A placeholder is committed so a fresh checkout compiles without requiring
// the npm build first; running the npm build populates the directory.
package web
import (
"embed"
"io/fs"
)
//go:embed all:out
var distFS embed.FS
// Dist returns the embedded SPA bundle rooted at the out/ directory.
// Returns nil if the bundle is empty (unbuilt) — the API server then renders
// a small "frontend not built" notice.
func Dist() fs.FS {
sub, err := fs.Sub(distFS, "out")
if err != nil {
return nil
}
// Treat an empty bundle (only the placeholder marker) as nil so the
// server falls back to the no-bundle notice instead of serving a stub.
if isEmpty(sub) {
return nil
}
return sub
}
func isEmpty(fsys fs.FS) bool {
entries, err := fs.ReadDir(fsys, ".")
if err != nil {
return true
}
for _, e := range entries {
if e.Name() == ".gitkeep" {
continue
}
return false
}
return true
}
+92
View File
@@ -0,0 +1,92 @@
// Catalog of node types the canvas can drop. Mirrors the executors registered
// in pkg/executors/registry.go RegisterAll(). Keep this in sync with the Go
// registry — anything listed here without a matching executor will fail at run
// time with "executor not implemented for node type".
import type { ComponentType } from "react";
import { Play, Settings2, Pause, CircleSlash } from "lucide-react";
export type CatalogEntry = {
/** Runtime type string: flow-nodes-base.X */
type: string;
/** Display label */
label: string;
/** Short description shown in palette/inspector */
description: string;
/** Lucide icon */
icon: ComponentType<{ className?: string }>;
/** Tailwind colour class for the node header */
color: string;
/** Number of outputs (for source handles) */
outputs: number;
/** Default parameters when dropped */
defaults: Record<string, unknown>;
/** Default Settings (retry etc.) */
settings?: Record<string, unknown>;
/** Group in palette */
group: "trigger" | "transform" | "human";
};
// Only the executors registered in pkg/executors/registry.go::RegisterAll().
export const CATALOG: CatalogEntry[] = [
{
type: "flow-nodes-base.trigger",
label: "Trigger",
description: "Pipeline entry point. Every pipeline needs exactly one.",
icon: Play,
color: "bg-emerald-500",
outputs: 1,
defaults: {},
group: "trigger",
},
{
type: "flow-nodes-base.set",
label: "Set",
description: "Define or transform fields on each item.",
icon: Settings2,
color: "bg-sky-500",
outputs: 1,
defaults: { values: { string: [] } },
group: "transform",
},
{
type: "flow-nodes-base.noOp",
label: "No-op",
description: "Pass items through unchanged.",
icon: CircleSlash,
color: "bg-slate-500",
outputs: 1,
defaults: {},
group: "transform",
},
{
type: "flow-nodes-base.waitForApproval",
label: "Wait for approval",
description: "Pause until a human resolves an awakeable.",
icon: Pause,
color: "bg-fuchsia-500",
outputs: 1,
defaults: { reason: "Manual review" },
group: "human",
},
];
export const GROUP_LABELS: Record<CatalogEntry["group"], string> = {
trigger: "Trigger",
transform: "Transform",
human: "Human-in-the-loop",
};
/** Look up by runtime type. Returns undefined for unsupported types so the
* caller can render a fallback "unsupported" node. */
export function lookup(type: string): CatalogEntry | undefined {
return CATALOG.find((c) => c.type === type);
}
/** Suggest a unique name like "Set", "Set 2", "Set 3". */
export function uniqueName(base: string, existing: Set<string>): string {
if (!existing.has(base)) return base;
let i = 2;
while (existing.has(`${base} ${i}`)) i++;
return `${base} ${i}`;
}
+111
View File
@@ -0,0 +1,111 @@
// Conversion between the n8n-format pipeline JSON the Go server stores and
// the {nodes, edges} shape React Flow renders. Keeping this in one place
// keeps the canvas dumb (React Flow state in, React Flow state out).
import type { Edge, Node } from "@xyflow/react";
// --- Pipeline JSON shape (matches pkg/models.NodeDef + n8n connection map) ---
export type PipelineNode = {
id: string;
name: string;
type: string;
typeVersion?: number;
parameters?: Record<string, unknown>;
credentials?: Record<string, unknown>;
settings?: Record<string, unknown>;
position: [number, number];
};
type ConnectionTarget = { node: string; type: string; index: number };
export type PipelineDefinition = {
name?: string;
nodes: PipelineNode[];
connections: Record<string, { main: ConnectionTarget[][] }>;
settings?: Record<string, unknown>;
};
export type FlowNodeData = {
pipelineNode: PipelineNode;
};
// --- to React Flow --------------------------------------------------------
export function toReactFlow(def: PipelineDefinition | null | undefined): {
nodes: Node<FlowNodeData>[];
edges: Edge[];
} {
const nodes: Node<FlowNodeData>[] = (def?.nodes ?? []).map((n) => ({
id: n.name, // n8n keys connections by name, so use name as RF id
type: "flowNode",
position: { x: n.position?.[0] ?? 0, y: n.position?.[1] ?? 0 },
data: { pipelineNode: n },
}));
const edges: Edge[] = [];
const conns = def?.connections ?? {};
for (const sourceName of Object.keys(conns)) {
const main = conns[sourceName]?.main ?? [];
main.forEach((targets, sourceOutputIndex) => {
(targets ?? []).forEach((t) => {
if (!t?.node) return;
edges.push({
id: `e:${sourceName}:${sourceOutputIndex}->${t.node}:${t.index ?? 0}`,
source: sourceName,
target: t.node,
sourceHandle: `o-${sourceOutputIndex}`,
targetHandle: `i-${t.index ?? 0}`,
});
});
});
}
return { nodes, edges };
}
// --- from React Flow ------------------------------------------------------
export function fromReactFlow(
rfNodes: Node<FlowNodeData>[],
rfEdges: Edge[],
base: { name?: string; settings?: Record<string, unknown> } = {}
): PipelineDefinition {
const nodes: PipelineNode[] = rfNodes.map((rn) => {
const pn = rn.data?.pipelineNode;
return {
id: pn?.id ?? rn.id,
name: pn?.name ?? rn.id,
type: pn?.type ?? "flow-nodes-base.noOp",
typeVersion: pn?.typeVersion ?? 1,
parameters: pn?.parameters ?? {},
...(pn?.credentials ? { credentials: pn.credentials } : {}),
...(pn?.settings ? { settings: pn.settings } : {}),
position: [Math.round(rn.position.x), Math.round(rn.position.y)],
};
});
// Build connections map keyed by source node name. n8n shape:
// connections[src].main[outputIndex] = [{ node, type: "main", index }, ...]
const connections: PipelineDefinition["connections"] = {};
for (const e of rfEdges) {
const outIdx = parseHandleIndex(e.sourceHandle, "o-");
const inIdx = parseHandleIndex(e.targetHandle, "i-");
const slot = (connections[e.source] ??= { main: [] });
while (slot.main.length <= outIdx) slot.main.push([]);
slot.main[outIdx].push({ node: e.target, type: "main", index: inIdx });
}
return {
...(base.name !== undefined ? { name: base.name } : {}),
nodes,
connections,
...(base.settings ? { settings: base.settings } : {}),
};
}
function parseHandleIndex(h: string | null | undefined, prefix: string): number {
if (!h || !h.startsWith(prefix)) return 0;
const n = Number(h.slice(prefix.length));
return Number.isFinite(n) && n >= 0 ? n : 0;
}
+14 -4
View File
@@ -1,12 +1,22 @@
/** @type {import('next').NextConfig} */
const isProd = process.env.NODE_ENV === "production";
// Where the Go backend is listening during `npm run dev`.
const apiTarget = process.env.FLOW_API_URL ?? "http://localhost:8080";
const nextConfig = {
output: "export",
// Static export writes to ./out (Go embed reads from there).
// Leave distDir at the default ".next" so build artifacts and the
// export target don't collide.
// Static export only at build time — the Go binary embeds ./out.
// In dev, leave Next as a normal server so we can proxy /api → Go.
...(isProd ? { output: "export" } : {}),
images: { unoptimized: true },
trailingSlash: true,
reactStrictMode: true,
// Dev-only: forward API calls to the Go server.
async rewrites() {
if (isProd) return [];
return [{ source: "/api/:path*", destination: `${apiTarget}/api/:path*` }];
},
};
export default nextConfig;
+36
View File
@@ -0,0 +1,36 @@
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Long cache for fingerprinted Next assets
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Proxy API calls to the Go service.
# `flow` is the service name on the docker-compose network.
location /api/ {
proxy_pass http://flow:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Next static export uses trailingSlash=true; map directory paths to
# their index.html. Try the requested URI, then the directory, then the
# 404 page (Next's _not-found export).
location / {
try_files $uri $uri/ $uri.html $uri/index.html /index.html =404;
}
# Don't log the favicon nag
location = /favicon.ico { log_not_found off; access_log off; }
}
+230
View File
@@ -12,6 +12,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
@@ -1264,6 +1265,55 @@
"tslib": "^2.8.0"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
@@ -1294,6 +1344,38 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@xyflow/react": {
"version": "12.10.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.76",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@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"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1535,6 +1617,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -1625,6 +1713,111 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2886,12 +3079,49 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"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
}
}
}
}
}
+1
View File
@@ -14,6 +14,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
+1 -1
View File
@@ -17,6 +17,6 @@
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": ["next-env.d.ts", "types/**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+4
View File
@@ -0,0 +1,4 @@
// Allow side-effect CSS imports like `import "@xyflow/react/dist/style.css"`.
// Next/webpack handles the actual loading; TS just needs to know the module
// exists.
declare module "*.css";