mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
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:
@@ -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
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
# Local Go binaries
|
||||
bin/
|
||||
tmp/
|
||||
flow
|
||||
*.test
|
||||
coverage.out
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/bin/
|
||||
/tmp/
|
||||
/flow
|
||||
/*.db
|
||||
/*.db-shm
|
||||
|
||||
+2
-10
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 5–7) and the bootstrap surface for platform engineers (steps 1–4 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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "/" }];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Generated
+230
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -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";
|
||||
Reference in New Issue
Block a user