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
+238 -112
View File
@@ -1,40 +1,53 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Play, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
ArrowLeft,
FileJson,
LayoutGrid,
Play,
Save,
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { PipelineCanvas } from "@/components/canvas/pipeline-canvas";
import { api, type StoredFlow } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import type { PipelineDefinition } from "@/lib/pipeline-graph";
export default function FlowDetailPage() {
type Tab = "canvas" | "json";
const EMPTY: PipelineDefinition = { nodes: [], connections: {} };
export default function PipelineDetailPage() {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading</div>}>
<FlowDetail />
<Suspense fallback={<div className="p-6 text-sm text-muted-foreground">Loading</div>}>
<PipelineDetail />
</Suspense>
);
}
function FlowDetail() {
function PipelineDetail() {
const router = useRouter();
const params = useSearchParams();
const id = params.get("id") ?? "";
const [flow, setFlow] = useState<StoredFlow | null>(null);
const [name, setName] = useState("");
const [definition, setDefinition] = useState("");
const [loaded, setLoaded] = useState(false);
const [nodeCount, setNodeCount] = useState(0);
const defRef = useRef<PipelineDefinition>(EMPTY);
const [jsonDraft, setJsonDraft] = useState("{}");
const [tab, setTab] = useState<Tab>("canvas");
const [showRun, setShowRun] = useState(false);
const [input, setInput] = useState("[{}]");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
@@ -43,14 +56,20 @@ function FlowDetail() {
async function load() {
if (!id) return;
setLoaded(false);
try {
const f = await api.getFlow(id);
const def = (f.definition as PipelineDefinition | null) ?? EMPTY;
setFlow(f);
setName(f.name);
setDefinition(JSON.stringify(f.definition, null, 2));
defRef.current = def;
setNodeCount(def.nodes?.length ?? 0);
setJsonDraft(JSON.stringify(def, null, 2));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "load failed");
} finally {
setLoaded(true);
}
}
@@ -59,12 +78,32 @@ function FlowDetail() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
function switchTab(next: Tab) {
if (next === "json") {
setJsonDraft(JSON.stringify({ ...defRef.current, name }, null, 2));
setTab(next);
return;
}
try {
const parsed = JSON.parse(jsonDraft) as PipelineDefinition;
defRef.current = parsed;
setNodeCount(parsed.nodes?.length ?? 0);
if (parsed.name) setName(parsed.name);
setError(null);
} catch (e) {
setError(`JSON parse failed: ${(e as Error).message}`);
return;
}
setTab(next);
}
async function onSave() {
setSaving(true);
setError(null);
try {
const parsed = JSON.parse(definition);
await api.updateFlow(id, { name, definition: parsed });
const toSave: PipelineDefinition =
tab === "json" ? JSON.parse(jsonDraft) : defRef.current;
await api.updateFlow(id, { name, definition: { ...toSave, name } });
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "save failed");
@@ -83,9 +122,14 @@ function FlowDetail() {
if (!Array.isArray(v)) throw new Error("Input must be a JSON array");
parsedInput = v;
}
const res = await api.executeWorkflow({ workflow_id: id, input: parsedInput });
const res = await api.executeWorkflow({
workflow_id: id,
input: parsedInput,
});
setLastExecId(res.execution_id);
router.push(`/executions/view/?id=${encodeURIComponent(res.execution_id)}`);
router.push(
`/executions/view/?id=${encodeURIComponent(res.execution_id)}`
);
} catch (e) {
setError(e instanceof Error ? e.message : "run failed");
} finally {
@@ -94,7 +138,7 @@ function FlowDetail() {
}
async function onDelete() {
if (!confirm("Delete this flow? This cannot be undone.")) return;
if (!confirm("Delete this pipeline? This cannot be undone.")) return;
try {
await api.deleteFlow(id);
router.push("/");
@@ -105,111 +149,193 @@ function FlowDetail() {
if (!id) {
return (
<div className="text-sm text-muted-foreground">
<div className="p-6 text-sm text-muted-foreground">
Missing <code>id</code> query param.
</div>
);
}
return (
<div className="space-y-6">
{flow && (
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<span className="font-mono">{flow.id}</span>
<span>·</span>
<span>updated {formatDate(flow.updatedAt)}</span>
<Badge variant="secondary">{flow.nodeCount} nodes</Badge>
</div>
)}
<div>
<h1 className="text-3xl font-semibold tracking-tight">
{name || "Untitled flow"}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Edit, save, and run this workflow against the orchestrator.
</p>
<div className="flex h-screen flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b bg-background/80 px-3 backdrop-blur">
<Button variant="ghost" size="icon" asChild aria-label="Back">
<Link href="/">
<ArrowLeft className="size-4" />
</Link>
</Button>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Pipeline name"
className="h-8 max-w-[280px] text-sm"
/>
<Badge variant="secondary" className="shrink-0">
{nodeCount} nodes
</Badge>
{flow && (
<span className="hidden truncate font-mono text-[10px] text-muted-foreground md:inline">
{flow.id}
</span>
)}
<div className="flex-1" />
<Tabs value={tab} onChange={switchTab} />
<Button variant="outline" size="sm" onClick={() => setShowRun((v) => !v)}>
<Play />
Run
</Button>
<Button onClick={onSave} disabled={saving} size="sm">
<Save />
{saving ? "Saving…" : "Save"}
</Button>
<Button
variant="ghost"
size="icon"
onClick={onDelete}
aria-label="Delete pipeline"
className="text-destructive hover:bg-destructive/10"
>
<Trash2 />
</Button>
</div>
{error && (
<Card className="border-destructive/40">
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
</Card>
<div className="border-b border-destructive/40 bg-destructive/5 px-4 py-2 text-xs text-destructive">
{error}
</div>
)}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Definition</CardTitle>
<CardDescription>n8n-format JSON</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
<div className="relative min-h-0 flex-1">
{tab === "canvas" ? (
loaded ? (
<PipelineCanvas
initialValue={defRef.current}
pipelineId={id}
onChange={(d) => {
defRef.current = d;
setNodeCount(d.nodes?.length ?? 0);
}}
fullBleed
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading pipeline
</div>
<div className="space-y-2">
<Label htmlFor="def">Workflow JSON</Label>
<Textarea
id="def"
rows={24}
value={definition}
onChange={(e) => setDefinition(e.target.value)}
spellCheck={false}
className="text-xs"
/>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={onDelete}>
<Trash2 />
Delete
</Button>
<Button onClick={onSave} disabled={saving}>
<Save />
{saving ? "Saving…" : "Save"}
</Button>
</div>
</CardContent>
</Card>
)
) : (
<div className="h-full p-4">
<Textarea
value={jsonDraft}
onChange={(e) => setJsonDraft(e.target.value)}
spellCheck={false}
className="h-full text-xs"
/>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Run</CardTitle>
<CardDescription>Submit to the orchestrator</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="input">Trigger input (JSON array)</Label>
<Textarea
id="input"
rows={8}
value={input}
onChange={(e) => setInput(e.target.value)}
spellCheck={false}
className="text-xs"
/>
<p className="text-xs text-muted-foreground">
e.g. <code className="font-mono">[{`{"foo":"bar"}`}]</code>
</p>
</div>
<Button className="w-full" onClick={onRun} disabled={running}>
<Play />
{running ? "Submitting…" : "Run flow"}
</Button>
{lastExecId && (
<div className="rounded-md border bg-muted/30 p-3 text-xs">
<div className="font-medium">Last execution</div>
<Link
href={`/executions/view/?id=${encodeURIComponent(lastExecId)}`}
className="font-mono text-primary hover:underline"
>
{lastExecId}
</Link>
</div>
)}
</CardContent>
</Card>
{showRun && (
<RunPanel
input={input}
onInput={setInput}
running={running}
onRun={onRun}
lastExecId={lastExecId}
onClose={() => setShowRun(false)}
/>
)}
</div>
</div>
);
}
function RunPanel(props: {
input: string;
onInput: (v: string) => void;
running: boolean;
onRun: () => void;
lastExecId: string | null;
onClose: () => void;
}) {
return (
<div className="absolute right-4 top-4 z-20 w-80 rounded-lg border bg-background p-4 shadow-xl">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold">Run pipeline</div>
<Button
variant="ghost"
size="icon"
onClick={props.onClose}
aria-label="Close"
>
<X className="size-4" />
</Button>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="input">Trigger input (JSON array)</Label>
<Textarea
id="input"
rows={6}
value={props.input}
onChange={(e) => props.onInput(e.target.value)}
spellCheck={false}
className="text-xs"
/>
<p className="text-[11px] text-muted-foreground">
e.g. <code className="font-mono">[{`{"foo":"bar"}`}]</code>
</p>
</div>
<Button
className="w-full"
onClick={props.onRun}
disabled={props.running}
>
<Play />
{props.running ? "Submitting…" : "Run"}
</Button>
{props.lastExecId && (
<div className="rounded-md border bg-muted/30 p-2 text-[11px]">
<div className="font-medium">Last execution</div>
<Link
href={`/executions/view/?id=${encodeURIComponent(props.lastExecId)}`}
className="font-mono text-primary hover:underline"
>
{props.lastExecId}
</Link>
</div>
)}
</div>
</div>
);
}
function Tabs({ value, onChange }: { value: Tab; onChange: (next: Tab) => void }) {
return (
<div className="inline-flex rounded-md border bg-muted/30 p-0.5">
<button
type="button"
onClick={() => onChange("canvas")}
className={
"flex items-center gap-1 rounded-sm px-2 py-1 text-xs transition-colors " +
(value === "canvas"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
<LayoutGrid className="size-3.5" />
Canvas
</button>
<button
type="button"
onClick={() => onChange("json")}
className={
"flex items-center gap-1 rounded-sm px-2 py-1 text-xs transition-colors " +
(value === "json"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
<FileJson className="size-3.5" />
Raw JSON
</button>
</div>
);
}