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

- Introduced NodePalette component to display draggable nodes categorized by type.
- Implemented PipelineCanvas component to manage the flow of nodes and connections.
- Integrated drag-and-drop functionality for adding nodes to the canvas.
- Created a catalog of node types with associated metadata for rendering in the palette.
- Established conversion functions between pipeline definitions and React Flow state.
- Added inspector for editing node properties and managing connections.
- Updated Next.js configuration for production and development environments.
- Removed obsolete embed.go file and added nginx configuration for serving the app.
- Updated package dependencies including @xyflow/react for enhanced functionality.
- Added TypeScript definitions for CSS imports to support styling in components.
This commit is contained in:
patel-lyzr
2026-05-13 22:15:08 +05:30
parent 2e94e6bdf6
commit 64a857d1b7
36 changed files with 2265 additions and 498 deletions
+53 -88
View File
@@ -1,9 +1,8 @@
// Package api provides the HTTP server for flow.
//
// It serves:
// - /api/health — readiness probe
// - /api/workflows — workflow CRUD (in-memory placeholder until storage lands)
// - / — embedded SPA (any non-/api path returns index.html)
// It serves only the JSON API under /api/*. The UI is a separate process
// (see ./web — nginx in prod, `next dev` locally) that proxies /api to here.
// Anything outside /api returns 404.
//
// The router is intentionally std-library only at this stage; we'll lift in a
// proper router (chi or gin) when the API surface grows.
@@ -15,7 +14,6 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"strings"
@@ -38,19 +36,21 @@ type FlowSummary struct {
}
// ServerDeps groups the construction-time dependencies of the HTTP server.
// Assets is the embedded UI bundle; Orchestrator drives durable execution;
// RestateIngressURL is used to resolve Awakeables from the resume handler.
// Orchestrator drives durable execution; RestateIngressURL is used to
// resolve Awakeables from the resume handler. CORSOrigins lists allowed
// browser origins (use "*" to allow any — fine for dev).
type ServerDeps struct {
Assets fs.FS
Orchestrator orchestrator.Orchestrator
RestateIngressURL string
CORSOrigins []string
}
// Server is a thin HTTP server that bundles API + embedded SPA.
// Server is a thin JSON API server. It does not serve a frontend.
type Server struct {
mux *http.ServeMux
orch orchestrator.Orchestrator
restateIngres string
corsOrigins []string
mu sync.Mutex
flows map[string]storedFlow // in-memory placeholder; storage layer lands next
@@ -66,25 +66,32 @@ type storedFlow struct {
NodeCount int `json:"nodeCount"`
}
// NewServer constructs a Server with API routes installed and the SPA mounted
// at "/" using deps.Assets. deps.Orchestrator may be nil — execute routes will
// then return 503.
// NewServer constructs an API-only Server. deps.Orchestrator may be nil —
// execute routes will then return 503.
func NewServer(deps ServerDeps) *Server {
s := &Server{
mux: http.NewServeMux(),
orch: deps.Orchestrator,
restateIngres: deps.RestateIngressURL,
corsOrigins: deps.CORSOrigins,
flows: make(map[string]storedFlow),
}
s.routes(deps.Assets)
s.routes()
return s
}
// ServeHTTP implements http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
// ServeHTTP implements http.Handler. CORS is applied here so all routes
// (including OPTIONS preflight) get the headers.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.applyCORS(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
s.mux.ServeHTTP(w, r)
}
func (s *Server) routes(assets fs.FS) {
// API
func (s *Server) routes() {
s.mux.HandleFunc("GET /api/health", s.handleHealth)
s.mux.HandleFunc("GET /api/workflows", s.handleListFlows)
s.mux.HandleFunc("POST /api/workflows", s.handleCreateFlow)
@@ -95,8 +102,35 @@ func (s *Server) routes(assets fs.FS) {
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
s.mux.HandleFunc("POST /api/executions/{id}/resume", s.handleResumeExecution)
// SPA: any path not starting with /api falls through to the embedded bundle.
s.mux.Handle("/", spaHandler(assets))
// Anything not under /api/ is not our concern — the UI server handles it.
s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, fmt.Errorf("no handler for %s", r.URL.Path))
})
}
// applyCORS sets the response headers needed for the UI process to call us
// from a different origin. In dev that's http://localhost:3000; in prod it's
// the nginx web container (same origin via proxy, but harmless to allow).
func (s *Server) applyCORS(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
return
}
allowed := false
for _, o := range s.corsOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
if !allowed {
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
w.Header().Set("Access-Control-Max-Age", "600")
}
// --- handlers -------------------------------------------------------------
@@ -386,75 +420,6 @@ func (s *Server) parseExecuteRequest(body []byte) (json.RawMessage, []models.Ite
return nil, nil, errors.New("either workflow or workflow_id is required")
}
// --- SPA -----------------------------------------------------------------
// spaHandler serves static assets from fsys; falls back to index.html for any
// non-asset GET so client-side routing works on refresh.
func spaHandler(fsys fs.FS) http.Handler {
if fsys == nil {
// Frontend not built; render a small notice instead of a blank page.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, errors.New("frontend not built"))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(noBundleHTML))
})
}
files := http.FS(fsys)
fileServer := http.FileServer(files)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, errors.New("not found"))
return
}
// If the requested asset exists, serve it. Otherwise fall back to index.html.
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
serveIndex(w, r, fsys)
return
}
if f, err := fsys.Open(path); err == nil {
_ = f.Close()
// Long cache for fingerprinted assets, no-cache for index.html.
if strings.HasPrefix(path, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
fileServer.ServeHTTP(w, r)
return
}
serveIndex(w, r, fsys)
})
}
func serveIndex(w http.ResponseWriter, _ *http.Request, fsys fs.FS) {
data, err := fs.ReadFile(fsys, "index.html")
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(data)
}
const noBundleHTML = `<!doctype html>
<html><head><title>flow</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.card{max-width:420px;padding:32px;border:1px solid #334155;border-radius:12px;background:#1e293b}
h1{margin:0 0 8px;font-size:18px}
code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:12px}
</style></head>
<body><div class="card">
<h1>flow — frontend not built</h1>
<p>Run <code>cd web && npm install && npm run build</code> and rebuild the binary to ship the UI.</p>
</div></body></html>`
// --- helpers --------------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v any) {
+4 -7
View File
@@ -330,18 +330,15 @@ func TestResume_unwiredIngress503(t *testing.T) {
}
}
func TestSPA_fallsBackToIndexForUnknownRoutes(t *testing.T) {
// Without any Assets, SPA handler returns the no-bundle notice.
func TestNonAPI_returns404(t *testing.T) {
// API server no longer hosts the SPA — the UI is a separate process.
srv := NewServer(ServerDeps{})
for _, path := range []string{"/", "/flows/abc", "/runs/123"} {
r := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("%s: got %d", path, w.Code)
}
if !strings.Contains(w.Body.String(), "html") {
t.Errorf("%s: expected html body", path)
if w.Code != http.StatusNotFound {
t.Errorf("%s: got %d, want 404", path, w.Code)
}
}
}