├── .gitattributes ├── .gitignore ├── LICENSE.md ├── README.md ├── components.json ├── docs ├── agent-chat.png ├── dashboard-overview.png └── n8n-screen.png ├── eslint.config.mjs ├── next.config.ts ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── src ├── app │ ├── api │ │ ├── anthropic │ │ │ └── route.ts │ │ └── n8n │ │ │ ├── [...path] │ │ │ └── route.ts │ │ │ ├── executions │ │ │ ├── [id] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ │ └── workflows │ │ │ └── [id] │ │ │ ├── activate │ │ │ └── route.ts │ │ │ ├── deactivate │ │ │ └── route.ts │ │ │ └── route.ts │ ├── dashboard │ │ ├── _components │ │ │ ├── agent-chat.tsx │ │ │ └── require-api-config.tsx │ │ ├── chat │ │ │ ├── _components │ │ │ │ └── agent-chat.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── settings │ │ │ └── page.tsx │ │ └── workflows │ │ │ ├── [id] │ │ │ ├── _components │ │ │ │ ├── execution-averages.tsx │ │ │ │ ├── execution-waterfall.tsx │ │ │ │ ├── workflow-chat.tsx │ │ │ │ └── workflow-timeline.tsx │ │ │ └── page.tsx │ │ │ ├── _components │ │ │ └── workflow-stats.tsx │ │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components │ ├── app-sidebar.tsx │ ├── nav-main.tsx │ ├── nav-projects.tsx │ ├── nav-user.tsx │ ├── team-switcher.tsx │ └── ui │ │ ├── avatar.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── collapsible.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ └── tooltip.tsx ├── hooks │ └── use-mobile.tsx └── lib │ ├── api │ ├── n8n-provider.tsx │ └── n8n.ts │ └── utils.ts ├── tailwind.config.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | *.mp4 filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Giga Guild LLC 2 | 3 | Portions of this software are licensed as follows: 4 | 5 | - All third party components incorporated into the software is licensed under the original license provided by the owner of the applicable component. 6 | - Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below. 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Agent Ops Dashboard 2 | 3 | A modern dashboard for managing and monitoring your n8n agents. "Cursor for agents" 4 | 5 |
6 | n8n Dashboard Overview 7 |
8 | Dashboard Overview: Monitor your agents and executions 9 |

10 | AI Agent Chat Interface 11 |
12 | AI Agent Chat: Create and manage your n8n agents with natural language 13 |

14 | n8n Workflow Editor 15 |
16 | n8n Workflow Editor: Build and configure your agents visually 17 |
18 | 19 | ## Watch it in Action 20 | 21 | https://github.com/user-attachments/assets/4145677b-8ac2-4bd5-9d9a-4798f46eadb4 22 | 23 | 24 | ## Getting Started 25 | 26 | First, install the dependencies: 27 | 28 | ```bash 29 | npm install 30 | # or 31 | yarn install 32 | # or 33 | pnpm install 34 | ``` 35 | 36 | Then, run the development server: 37 | 38 | ```bash 39 | npm run dev 40 | # or 41 | yarn dev 42 | # or 43 | pnpm dev 44 | ``` 45 | 46 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the dashboard. 47 | 48 | ## Configuration 49 | 50 | ### Required Settings 51 | 52 | Before using the dashboard, you need to configure the following settings: 53 | 54 | 1. **n8n Instance Configuration** 55 | - The dashboard works with any n8n instance: 56 | - [n8n Cloud](https://www.n8n.io/cloud/): Use n8n's managed cloud service 57 | - Self-hosted: Install and run n8n on your own infrastructure ([Self-hosting guide](https://docs.n8n.io/hosting/)) 58 | - Enter your n8n instance URL: 59 | - For n8n Cloud: Your cloud instance URL (e.g., `https://your-workspace.app.n8n.cloud`) 60 | - For self-hosted: Your instance URL (e.g., `http://localhost:5678`) 61 | - Add your n8n API key 62 | - To get your API key: 63 | 1. Log into your n8n instance 64 | 2. Go to Settings > API 65 | 3. Create a new API key with appropriate permissions 66 | 4. Copy the generated key 67 | 68 | 2. **Anthropic API Key** 69 | - Go to the Settings page in the dashboard 70 | - Enter your Anthropic API key 71 | - To get an API key: 72 | 1. Visit [Anthropic's website](https://www.anthropic.com) 73 | 2. Sign up or log in to your account 74 | 3. Navigate to the API section 75 | 4. Generate a new API key 76 | 5. Copy the key 77 | 78 | ### Environment Variables 79 | 80 | If you prefer to set these values via environment variables, create a `.env.local` file in the root directory with: 81 | 82 | ```env 83 | NEXT_PUBLIC_N8N_URL=your_n8n_url 84 | NEXT_PUBLIC_N8N_API_KEY=your_n8n_api_key 85 | NEXT_PUBLIC_ANTHROPIC_API_KEY=your_anthropic_api_key 86 | ``` 87 | 88 | ## Features 89 | 90 | - Modern, responsive dashboard interface 91 | - Real-time agent monitoring 92 | - Execution history and statistics 93 | - AI-powered agent chat assistance 94 | - Workflow visualization and management 95 | - Integration with n8n's API 96 | 97 | ## Learn More 98 | 99 | To learn more about the technologies used in this project: 100 | 101 | - [Next.js Documentation](https://nextjs.org/docs) 102 | - [n8n Documentation](https://docs.n8n.io) 103 | - [Anthropic Claude Documentation](https://docs.anthropic.com) 104 | 105 | ## Contributing 106 | 107 | Contributions are welcome! Please feel free to submit a Pull Request. 108 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /docs/agent-chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rforgeon/AgentRails/6e55872de165cd9e49c23d9a4736785cf7476e81/docs/agent-chat.png -------------------------------------------------------------------------------- /docs/dashboard-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rforgeon/AgentRails/6e55872de165cd9e49c23d9a4736785cf7476e81/docs/dashboard-overview.png -------------------------------------------------------------------------------- /docs/n8n-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rforgeon/AgentRails/6e55872de165cd9e49c23d9a4736785cf7476e81/docs/n8n-screen.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "n8n-dashboard", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@anthropic-ai/sdk": "^0.36.3", 13 | "@hookform/resolvers": "^3.10.0", 14 | "@radix-ui/react-avatar": "^1.1.2", 15 | "@radix-ui/react-collapsible": "^1.1.2", 16 | "@radix-ui/react-dialog": "^1.1.5", 17 | "@radix-ui/react-dropdown-menu": "^2.1.5", 18 | "@radix-ui/react-label": "^2.1.1", 19 | "@radix-ui/react-separator": "^1.1.1", 20 | "@radix-ui/react-slot": "^1.1.1", 21 | "@radix-ui/react-tabs": "^1.1.2", 22 | "@radix-ui/react-tooltip": "^1.1.7", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "lucide-react": "^0.474.0", 26 | "next": "15.1.6", 27 | "react": "^19.0.0", 28 | "react-dom": "^19.0.0", 29 | "react-hook-form": "^7.54.2", 30 | "tailwind-merge": "^3.0.1", 31 | "tailwindcss-animate": "^1.0.7", 32 | "zod": "^3.24.1", 33 | "zustand": "^5.0.3" 34 | }, 35 | "devDependencies": { 36 | "@eslint/eslintrc": "^3", 37 | "@types/node": "^20", 38 | "@types/react": "^19", 39 | "@types/react-dom": "^19", 40 | "eslint": "^9", 41 | "eslint-config-next": "15.1.6", 42 | "postcss": "^8", 43 | "tailwindcss": "^3.4.1", 44 | "typescript": "^5" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/anthropic/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | import Anthropic from '@anthropic-ai/sdk'; 3 | 4 | interface Message { 5 | role: "user" | "assistant"; 6 | content: string; 7 | } 8 | 9 | export async function POST(request: NextRequest) { 10 | console.log("Received request headers:", Object.fromEntries(request.headers.entries())); 11 | 12 | const body = await request.json(); 13 | console.log("Received request body:", body); 14 | 15 | const { messages, systemPrompt } = body; 16 | const anthropicKey = request.headers.get('x-anthropic-key'); 17 | 18 | if (!anthropicKey) { 19 | console.error("Missing Anthropic API key in request headers"); 20 | return new Response( 21 | JSON.stringify({ 22 | error: "Missing Anthropic API key", 23 | }), 24 | { 25 | status: 400, 26 | headers: { 27 | "Content-Type": "application/json", 28 | }, 29 | } 30 | ); 31 | } 32 | 33 | try { 34 | const client = new Anthropic({ 35 | apiKey: anthropicKey 36 | }); 37 | 38 | // Create a TransformStream to convert the SDK stream to SSE 39 | const encoder = new TextEncoder(); 40 | const stream = new TransformStream({ 41 | async transform(chunk, controller) { 42 | controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); 43 | } 44 | }); 45 | 46 | const writer = stream.writable.getWriter(); 47 | 48 | // Start streaming response 49 | const response = new Response(stream.readable, { 50 | headers: { 51 | 'Content-Type': 'text/event-stream', 52 | 'Cache-Control': 'no-cache', 53 | 'Connection': 'keep-alive', 54 | }, 55 | }); 56 | 57 | // Process the message stream in the background 58 | (async () => { 59 | try { 60 | const messageStream = await client.messages.create({ 61 | max_tokens: 4096, 62 | messages: messages.map((msg: Message) => ({ 63 | role: msg.role === 'assistant' ? 'assistant' : 'user', 64 | content: msg.content 65 | })), 66 | model: 'claude-3-5-sonnet-latest', 67 | system: systemPrompt, 68 | stream: true, 69 | }); 70 | 71 | for await (const messageStreamEvent of messageStream) { 72 | await writer.write(messageStreamEvent); 73 | } 74 | } catch (error) { 75 | console.error('Error in message stream:', error); 76 | const errorMessage = { 77 | type: 'error', 78 | error: error instanceof Error ? error.message : String(error) 79 | }; 80 | await writer.write(errorMessage); 81 | } finally { 82 | await writer.close(); 83 | } 84 | })(); 85 | 86 | return response; 87 | } catch (error) { 88 | console.error("Error calling Anthropic API:", error); 89 | return new Response( 90 | JSON.stringify({ 91 | error: "Failed to call Anthropic API", 92 | details: error instanceof Error ? error.message : String(error), 93 | }), 94 | { 95 | status: 500, 96 | headers: { 97 | "Content-Type": "application/json", 98 | }, 99 | } 100 | ); 101 | } 102 | } -------------------------------------------------------------------------------- /src/app/api/n8n/[...path]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | 3 | export async function GET( 4 | request: NextRequest, 5 | { params }: { params: { path: string[] } } 6 | ) { 7 | const path = params.path.join("/"); 8 | const { searchParams } = new URL(request.url); 9 | const apiKey = searchParams.get("apiKey"); 10 | const baseUrl = searchParams.get("baseUrl"); 11 | 12 | if (!apiKey || !baseUrl) { 13 | return new Response("Missing API key or base URL", { status: 400 }); 14 | } 15 | 16 | // Remove trailing slash from baseUrl if present 17 | const cleanBaseUrl = baseUrl.replace(/\/$/, ""); 18 | const url = `${cleanBaseUrl}/api/v1/${path}`; 19 | 20 | try { 21 | console.log("Fetching from n8n:", url); 22 | const response = await fetch(url, { 23 | headers: { 24 | "X-N8N-API-KEY": apiKey, 25 | "Content-Type": "application/json", 26 | }, 27 | }); 28 | 29 | if (!response.ok) { 30 | console.error("n8n API error:", { 31 | status: response.status, 32 | statusText: response.statusText, 33 | }); 34 | const errorText = await response.text(); 35 | console.error("Error response:", errorText); 36 | 37 | return new Response( 38 | JSON.stringify({ 39 | error: "n8n API request failed", 40 | status: response.status, 41 | statusText: response.statusText, 42 | details: errorText, 43 | }), 44 | { 45 | status: response.status, 46 | headers: { 47 | "Content-Type": "application/json", 48 | }, 49 | } 50 | ); 51 | } 52 | 53 | const rawData = await response.json(); 54 | 55 | // Transform the response to include data property 56 | const transformedData = { 57 | data: Array.isArray(rawData) ? rawData : rawData.data || rawData, 58 | }; 59 | 60 | return new Response(JSON.stringify(transformedData), { 61 | headers: { 62 | "Content-Type": "application/json", 63 | }, 64 | }); 65 | } catch (error) { 66 | console.error("Proxy error:", error); 67 | return new Response( 68 | JSON.stringify({ 69 | error: "Failed to fetch data", 70 | details: error instanceof Error ? error.message : String(error), 71 | }), 72 | { 73 | status: 500, 74 | headers: { 75 | "Content-Type": "application/json", 76 | }, 77 | } 78 | ); 79 | } 80 | } 81 | 82 | export async function POST( 83 | request: NextRequest, 84 | { params }: { params: { path: string[] } } 85 | ) { 86 | const path = params.path.join("/"); 87 | const { searchParams } = new URL(request.url); 88 | const apiKey = searchParams.get("apiKey"); 89 | const baseUrl = searchParams.get("baseUrl"); 90 | 91 | if (!apiKey || !baseUrl) { 92 | return new Response("Missing API key or base URL", { status: 400 }); 93 | } 94 | 95 | try { 96 | const body = await request.json(); 97 | const cleanBaseUrl = baseUrl.replace(/\/$/, ""); 98 | const url = `${cleanBaseUrl}/api/v1/${path}`; 99 | 100 | console.log("Creating workflow in n8n:", url); 101 | const response = await fetch(url, { 102 | method: "POST", 103 | headers: { 104 | "X-N8N-API-KEY": apiKey, 105 | "Content-Type": "application/json", 106 | }, 107 | body: JSON.stringify(body), 108 | }); 109 | 110 | if (!response.ok) { 111 | console.error("n8n API error:", { 112 | status: response.status, 113 | statusText: response.statusText, 114 | }); 115 | const errorText = await response.text(); 116 | console.error("Error response:", errorText); 117 | 118 | return new Response( 119 | JSON.stringify({ 120 | error: "n8n API request failed", 121 | status: response.status, 122 | statusText: response.statusText, 123 | details: errorText, 124 | }), 125 | { 126 | status: response.status, 127 | headers: { 128 | "Content-Type": "application/json", 129 | }, 130 | } 131 | ); 132 | } 133 | 134 | const rawData = await response.json(); 135 | 136 | // Transform the response to include data property 137 | const transformedData = { 138 | data: rawData.data || rawData, 139 | }; 140 | 141 | return new Response(JSON.stringify(transformedData), { 142 | headers: { 143 | "Content-Type": "application/json", 144 | }, 145 | }); 146 | } catch (error) { 147 | console.error("Proxy error:", error); 148 | return new Response( 149 | JSON.stringify({ 150 | error: "Failed to create workflow", 151 | details: error instanceof Error ? error.message : String(error), 152 | }), 153 | { 154 | status: 500, 155 | headers: { 156 | "Content-Type": "application/json", 157 | }, 158 | } 159 | ); 160 | } 161 | } -------------------------------------------------------------------------------- /src/app/api/n8n/executions/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | 3 | export async function GET( 4 | request: NextRequest, 5 | { params }: { params: { id: string } } 6 | ) { 7 | const searchParams = request.nextUrl.searchParams; 8 | const apiKey = searchParams.get("apiKey"); 9 | const baseUrl = searchParams.get("baseUrl")?.replace(/\/$/, ""); 10 | 11 | if (!apiKey || !baseUrl) { 12 | return new Response( 13 | JSON.stringify({ 14 | error: "Missing required parameters", 15 | details: "API key and base URL are required", 16 | }), 17 | { 18 | status: 400, 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | } 23 | ); 24 | } 25 | 26 | try { 27 | const url = new URL(`${baseUrl}/api/v1/executions/${params.id}`); 28 | url.searchParams.set("includeData", "true"); 29 | 30 | console.log("Fetching execution from:", url.toString()); 31 | 32 | const response = await fetch(url, { 33 | headers: { 34 | "X-N8N-API-KEY": apiKey, 35 | }, 36 | }); 37 | 38 | const rawData = await response.json(); 39 | console.log("Raw n8n execution response:", rawData); 40 | 41 | if (!response.ok) { 42 | return new Response( 43 | JSON.stringify({ 44 | error: "Failed to fetch execution", 45 | status: response.status, 46 | statusText: response.statusText, 47 | details: rawData.message || "Unknown error", 48 | }), 49 | { 50 | status: response.status, 51 | headers: { 52 | "Content-Type": "application/json", 53 | }, 54 | } 55 | ); 56 | } 57 | 58 | // Transform the response to include data property 59 | const transformedData = { 60 | data: rawData.data || rawData, 61 | }; 62 | 63 | console.log("Transformed execution response:", transformedData); 64 | 65 | return new Response(JSON.stringify(transformedData), { 66 | headers: { 67 | "Content-Type": "application/json", 68 | }, 69 | }); 70 | } catch (error) { 71 | console.error("Error fetching execution:", error); 72 | return new Response( 73 | JSON.stringify({ 74 | error: "Failed to fetch execution", 75 | details: error instanceof Error ? error.message : String(error), 76 | }), 77 | { 78 | status: 500, 79 | headers: { 80 | "Content-Type": "application/json", 81 | }, 82 | } 83 | ); 84 | } 85 | } -------------------------------------------------------------------------------- /src/app/api/n8n/executions/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | 3 | export async function GET(request: NextRequest) { 4 | const searchParams = request.nextUrl.searchParams; 5 | const apiKey = searchParams.get("apiKey"); 6 | const baseUrl = searchParams.get("baseUrl")?.replace(/\/$/, ""); 7 | const workflowId = searchParams.get("workflowId"); 8 | 9 | if (!apiKey || !baseUrl) { 10 | return new Response( 11 | JSON.stringify({ 12 | error: "Missing required parameters", 13 | details: "API key and base URL are required", 14 | }), 15 | { 16 | status: 400, 17 | headers: { 18 | "Content-Type": "application/json", 19 | }, 20 | } 21 | ); 22 | } 23 | 24 | try { 25 | const url = new URL(workflowId 26 | ? `${baseUrl}/api/v1/executions` 27 | : `${baseUrl}/api/v1/executions`); 28 | 29 | // Add query parameters 30 | if (workflowId) { 31 | url.searchParams.set("workflowId", workflowId); 32 | } 33 | url.searchParams.set("includeData", "true"); 34 | 35 | console.log("Fetching executions from:", url.toString()); 36 | 37 | const response = await fetch(url, { 38 | headers: { 39 | "X-N8N-API-KEY": apiKey, 40 | }, 41 | }); 42 | 43 | const rawData = await response.json(); 44 | console.log("Raw n8n executions response:", rawData); 45 | 46 | if (!response.ok) { 47 | return new Response( 48 | JSON.stringify({ 49 | error: "Failed to fetch executions", 50 | status: response.status, 51 | statusText: response.statusText, 52 | details: rawData.message || "Unknown error", 53 | }), 54 | { 55 | status: response.status, 56 | headers: { 57 | "Content-Type": "application/json", 58 | }, 59 | } 60 | ); 61 | } 62 | 63 | // Transform the response to include data property 64 | // Handle both array and object with results property 65 | const transformedData = { 66 | data: Array.isArray(rawData) ? rawData : rawData.data || rawData, 67 | }; 68 | 69 | console.log("Transformed executions response:", transformedData); 70 | 71 | return new Response(JSON.stringify(transformedData), { 72 | headers: { 73 | "Content-Type": "application/json", 74 | }, 75 | }); 76 | } catch (error) { 77 | console.error("Error fetching executions:", error); 78 | return new Response( 79 | JSON.stringify({ 80 | error: "Failed to fetch executions", 81 | details: error instanceof Error ? error.message : String(error), 82 | }), 83 | { 84 | status: 500, 85 | headers: { 86 | "Content-Type": "application/json", 87 | }, 88 | } 89 | ); 90 | } 91 | } -------------------------------------------------------------------------------- /src/app/api/n8n/workflows/[id]/activate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | 3 | export async function POST( 4 | request: NextRequest, 5 | { params }: { params: { id: string } } 6 | ) { 7 | const searchParams = request.nextUrl.searchParams; 8 | const apiKey = searchParams.get("apiKey"); 9 | const baseUrl = searchParams.get("baseUrl")?.replace(/\/$/, ""); 10 | 11 | if (!apiKey || !baseUrl) { 12 | return new Response( 13 | JSON.stringify({ 14 | error: "Missing required parameters", 15 | details: "API key and base URL are required", 16 | }), 17 | { 18 | status: 400, 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | } 23 | ); 24 | } 25 | 26 | try { 27 | const url = `${baseUrl}/api/v1/workflows/${params.id}/activate`; 28 | console.log("Activating workflow at:", url); 29 | 30 | const response = await fetch(url, { 31 | method: "POST", 32 | headers: { 33 | "X-N8N-API-KEY": apiKey, 34 | }, 35 | }); 36 | 37 | const rawData = await response.json(); 38 | console.log("Raw n8n workflow activation response:", rawData); 39 | 40 | if (!response.ok) { 41 | return new Response( 42 | JSON.stringify({ 43 | error: "Failed to activate workflow", 44 | status: response.status, 45 | statusText: response.statusText, 46 | details: rawData.message || "Unknown error", 47 | }), 48 | { 49 | status: response.status, 50 | headers: { 51 | "Content-Type": "application/json", 52 | }, 53 | } 54 | ); 55 | } 56 | 57 | // Transform the response to include data property 58 | const transformedData = { 59 | data: rawData.data || rawData, 60 | }; 61 | 62 | console.log("Transformed workflow activation response:", transformedData); 63 | 64 | return new Response(JSON.stringify(transformedData), { 65 | headers: { 66 | "Content-Type": "application/json", 67 | }, 68 | }); 69 | } catch (error) { 70 | console.error("Error activating workflow:", error); 71 | return new Response( 72 | JSON.stringify({ 73 | error: "Failed to activate workflow", 74 | details: error instanceof Error ? error.message : String(error), 75 | }), 76 | { 77 | status: 500, 78 | headers: { 79 | "Content-Type": "application/json", 80 | }, 81 | } 82 | ); 83 | } 84 | } -------------------------------------------------------------------------------- /src/app/api/n8n/workflows/[id]/deactivate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | 3 | export async function POST( 4 | request: NextRequest, 5 | { params }: { params: { id: string } } 6 | ) { 7 | const searchParams = request.nextUrl.searchParams; 8 | const apiKey = searchParams.get("apiKey"); 9 | const baseUrl = searchParams.get("baseUrl")?.replace(/\/$/, ""); 10 | 11 | if (!apiKey || !baseUrl) { 12 | return new Response( 13 | JSON.stringify({ 14 | error: "Missing required parameters", 15 | details: "API key and base URL are required", 16 | }), 17 | { 18 | status: 400, 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | } 23 | ); 24 | } 25 | 26 | try { 27 | const url = `${baseUrl}/api/v1/workflows/${params.id}/deactivate`; 28 | console.log("Deactivating workflow at:", url); 29 | 30 | const response = await fetch(url, { 31 | method: "POST", 32 | headers: { 33 | "X-N8N-API-KEY": apiKey, 34 | }, 35 | }); 36 | 37 | const rawData = await response.json(); 38 | console.log("Raw n8n workflow deactivation response:", rawData); 39 | 40 | if (!response.ok) { 41 | return new Response( 42 | JSON.stringify({ 43 | error: "Failed to deactivate workflow", 44 | status: response.status, 45 | statusText: response.statusText, 46 | details: rawData.message || "Unknown error", 47 | }), 48 | { 49 | status: response.status, 50 | headers: { 51 | "Content-Type": "application/json", 52 | }, 53 | } 54 | ); 55 | } 56 | 57 | // Transform the response to include data property 58 | const transformedData = { 59 | data: rawData.data || rawData, 60 | }; 61 | 62 | console.log("Transformed workflow deactivation response:", transformedData); 63 | 64 | return new Response(JSON.stringify(transformedData), { 65 | headers: { 66 | "Content-Type": "application/json", 67 | }, 68 | }); 69 | } catch (error) { 70 | console.error("Error deactivating workflow:", error); 71 | return new Response( 72 | JSON.stringify({ 73 | error: "Failed to deactivate workflow", 74 | details: error instanceof Error ? error.message : String(error), 75 | }), 76 | { 77 | status: 500, 78 | headers: { 79 | "Content-Type": "application/json", 80 | }, 81 | } 82 | ); 83 | } 84 | } -------------------------------------------------------------------------------- /src/app/api/n8n/workflows/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | 3 | export async function GET( 4 | request: NextRequest, 5 | { params }: { params: { id: string } } 6 | ) { 7 | const searchParams = request.nextUrl.searchParams; 8 | const apiKey = searchParams.get("apiKey"); 9 | const baseUrl = searchParams.get("baseUrl")?.replace(/\/$/, ""); 10 | 11 | if (!apiKey || !baseUrl) { 12 | return new Response( 13 | JSON.stringify({ 14 | error: "Missing required parameters", 15 | details: "API key and base URL are required", 16 | }), 17 | { 18 | status: 400, 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | } 23 | ); 24 | } 25 | 26 | try { 27 | const url = `${baseUrl}/api/v1/workflows/${params.id}`; 28 | console.log("Fetching workflow from:", url); 29 | 30 | const response = await fetch(url, { 31 | headers: { 32 | "X-N8N-API-KEY": apiKey, 33 | }, 34 | }); 35 | 36 | const rawData = await response.json(); 37 | console.log("Raw n8n workflow response:", rawData); 38 | 39 | if (!response.ok) { 40 | return new Response( 41 | JSON.stringify({ 42 | error: "Failed to fetch workflow", 43 | status: response.status, 44 | statusText: response.statusText, 45 | details: rawData.message || "Unknown error", 46 | }), 47 | { 48 | status: response.status, 49 | headers: { 50 | "Content-Type": "application/json", 51 | }, 52 | } 53 | ); 54 | } 55 | 56 | // Transform the response to include data property 57 | const transformedData = { 58 | data: rawData.data || rawData, 59 | }; 60 | 61 | console.log("Transformed workflow response:", transformedData); 62 | 63 | return new Response(JSON.stringify(transformedData), { 64 | headers: { 65 | "Content-Type": "application/json", 66 | }, 67 | }); 68 | } catch (error) { 69 | console.error("Error fetching workflow:", error); 70 | return new Response( 71 | JSON.stringify({ 72 | error: "Failed to fetch workflow", 73 | details: error instanceof Error ? error.message : String(error), 74 | }), 75 | { 76 | status: 500, 77 | headers: { 78 | "Content-Type": "application/json", 79 | }, 80 | } 81 | ); 82 | } 83 | } 84 | 85 | export async function PUT( 86 | request: NextRequest, 87 | { params }: { params: { id: string } } 88 | ) { 89 | const searchParams = request.nextUrl.searchParams; 90 | const apiKey = searchParams.get("apiKey"); 91 | const baseUrl = searchParams.get("baseUrl")?.replace(/\/$/, ""); 92 | 93 | if (!apiKey || !baseUrl) { 94 | return new Response( 95 | JSON.stringify({ 96 | error: "Missing required parameters", 97 | details: "API key and base URL are required", 98 | }), 99 | { 100 | status: 400, 101 | headers: { 102 | "Content-Type": "application/json", 103 | }, 104 | } 105 | ); 106 | } 107 | 108 | try { 109 | const workflowData = await request.json(); 110 | const url = `${baseUrl}/api/v1/workflows/${params.id}`; 111 | console.log("Updating workflow at:", url); 112 | console.log("Update data:", workflowData); 113 | 114 | const response = await fetch(url, { 115 | method: "PUT", 116 | headers: { 117 | "X-N8N-API-KEY": apiKey, 118 | "Content-Type": "application/json", 119 | }, 120 | body: JSON.stringify(workflowData), 121 | }); 122 | 123 | const rawData = await response.json(); 124 | console.log("Raw n8n workflow update response:", rawData); 125 | 126 | if (!response.ok) { 127 | return new Response( 128 | JSON.stringify({ 129 | error: "Failed to update workflow", 130 | status: response.status, 131 | statusText: response.statusText, 132 | details: rawData.message || "Unknown error", 133 | }), 134 | { 135 | status: response.status, 136 | headers: { 137 | "Content-Type": "application/json", 138 | }, 139 | } 140 | ); 141 | } 142 | 143 | // Transform the response to include data property 144 | const transformedData = { 145 | data: rawData.data || rawData, 146 | }; 147 | 148 | console.log("Transformed workflow update response:", transformedData); 149 | 150 | return new Response(JSON.stringify(transformedData), { 151 | headers: { 152 | "Content-Type": "application/json", 153 | }, 154 | }); 155 | } catch (error) { 156 | console.error("Error updating workflow:", error); 157 | return new Response( 158 | JSON.stringify({ 159 | error: "Failed to update workflow", 160 | details: error instanceof Error ? error.message : String(error), 161 | }), 162 | { 163 | status: 500, 164 | headers: { 165 | "Content-Type": "application/json", 166 | }, 167 | } 168 | ); 169 | } 170 | } -------------------------------------------------------------------------------- /src/app/dashboard/_components/agent-chat.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | import { Card } from "@/components/ui/card"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useN8n } from "@/lib/api/n8n-provider"; 6 | import { Sparkles, Send, Check, X } from "lucide-react"; 7 | 8 | interface Message { 9 | role: "user" | "assistant"; 10 | content: string; 11 | suggestedUpdate?: { 12 | workflow: any; 13 | description: string; 14 | }; 15 | id?: string; 16 | } 17 | 18 | const MASTER_PROMPT = `You are an expert n8n workflow developer, specializing in creating and configuring AI/LLM agents using nodes from the n8n Langchain integration. Your role is to help users build and configure n8n workflows that utilize these nodes effectively. 19 | 20 | When suggesting workflow changes: 21 | 1. Analyze the user's request carefully 22 | 2. Explain the changes you're going to make in a clear, step-by-step format 23 | 3. Include the complete updated workflow configuration in your response, wrapped in \`\`\`json\`\`\` code blocks 24 | 4. The JSON should contain the full workflow data structure under a "workflow" key 25 | 5. Make sure to preserve all existing workflow properties (id, name, settings, etc.) 26 | 6. Format your response with clear sections: 27 | - Explanation of changes 28 | - Step-by-step modifications 29 | - JSON configuration block 30 | - Next steps or additional suggestions 31 | 32 | When building or updating agents, use these core AI/LLM nodes: 33 | - @n8n/n8n-nodes-langchain.agent: Orchestrate tasks with LangChain 34 | - @n8n/n8n-nodes-langchain.chatTrigger: Initiate workflows from chat messages 35 | - @n8n/n8n-nodes-langchain.conversationalRetrievalQA: Combine conversation context with retrieval QA 36 | - @n8n/n8n-nodes-langchain.llm: Interface with language models 37 | - @n8n/n8n-nodes-langchain.llmChain: Orchestrate sequential language processing 38 | - @n8n/n8n-nodes-langchain.prompt: Manage dynamic prompts 39 | - @n8n/n8n-nodes-langchain.retrievalQA: Answer questions using retrieved documents 40 | 41 | For integrations with external services, you have access to a wide range of nodes including: 42 | - Communication: Discord, Slack, Email, Intercom, SendGrid, Mailchimp 43 | - Documents: Google Drive, Dropbox, OneDrive, Box, SharePoint 44 | - Databases: MongoDB, MySQL, PostgreSQL, Airtable, Notion 45 | - CRM/Sales: Salesforce, HubSpot, Pipedrive, Zendesk 46 | - Project Management: Jira, Asana, Trello, ClickUp, Monday.com 47 | - Social Media: Twitter, LinkedIn, Facebook, Instagram 48 | - Development: GitHub, GitLab, Bitbucket 49 | - And many more specialized integration nodes 50 | 51 | Best Practices: 52 | 1. Start with a Chat Trigger node (@n8n/n8n-nodes-langchain.chatTrigger) for chat-based workflows 53 | 2. Position nodes logically in the workflow layout: 54 | - Input/trigger nodes at the top 55 | - Processing/transformation nodes in the middle 56 | - Output/action nodes at the bottom 57 | 3. Configure proper connections between nodes: 58 | - Ensure data flow matches the expected input/output formats 59 | - Use Set node for data transformation when needed 60 | 4. Implement error handling: 61 | - Add Error Trigger nodes for failure scenarios 62 | - Configure retries for external service calls 63 | - Add logging for debugging purposes 64 | 5. Consider performance and cost: 65 | - Cache results when possible 66 | - Use batching for bulk operations 67 | - Implement rate limiting for API calls 68 | 69 | When providing responses: 70 | 1. Use clear, structured formatting with headers and sections 71 | 2. Include code blocks with proper syntax highlighting 72 | 3. Explain technical concepts in user-friendly language 73 | 4. Provide context for why each node and configuration is chosen 74 | 5. Suggest optimizations and improvements 75 | 6. Include example data structures when relevant 76 | 77 | Remember to maintain a helpful and informative tone while providing practical, implementable solutions that follow n8n best practices.`; 78 | 79 | export function AgentChat() { 80 | const { client, anthropicKey } = useN8n(); 81 | const [messages, setMessages] = useState([]); 82 | const [input, setInput] = useState(""); 83 | const [isProcessing, setIsProcessing] = useState(false); 84 | const [isStreaming, setIsStreaming] = useState(false); 85 | const messagesEndRef = useRef(null); 86 | const messagesContainerRef = useRef(null); 87 | const [shouldAutoScroll, setShouldAutoScroll] = useState(true); 88 | 89 | const scrollToBottom = () => { 90 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); 91 | }; 92 | 93 | // Handle scroll events to detect user interaction 94 | const handleScroll = () => { 95 | if (!messagesContainerRef.current || !isStreaming) return; 96 | 97 | const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; 98 | const isAtBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 50; 99 | setShouldAutoScroll(isAtBottom); 100 | }; 101 | 102 | // Auto-scroll effect during streaming 103 | useEffect(() => { 104 | if (isStreaming && shouldAutoScroll) { 105 | scrollToBottom(); 106 | } 107 | }, [messages, isStreaming, shouldAutoScroll]); 108 | 109 | const handleSend = async () => { 110 | if (!input.trim() || isProcessing || !anthropicKey) return; 111 | 112 | const userMessage: Message = { role: "user", content: input }; 113 | setMessages(prev => [...prev, userMessage]); 114 | setInput(""); 115 | setIsProcessing(true); 116 | setIsStreaming(true); 117 | 118 | let streamedContent = ''; 119 | const tempMessageId = Date.now().toString(); 120 | 121 | // Add a temporary message for streaming 122 | setMessages(prev => [...prev, { 123 | role: 'assistant', 124 | content: '', 125 | id: tempMessageId 126 | }]); 127 | 128 | try { 129 | const response = await fetch('/api/anthropic', { 130 | method: 'POST', 131 | headers: { 132 | 'content-type': 'application/json', 133 | 'x-anthropic-key': anthropicKey 134 | }, 135 | body: JSON.stringify({ 136 | messages: [ 137 | ...messages.map(msg => ({ 138 | role: msg.role, 139 | content: msg.content 140 | })), 141 | { 142 | role: 'user', 143 | content: input 144 | } 145 | ], 146 | systemPrompt: MASTER_PROMPT 147 | }) 148 | }); 149 | 150 | if (!response.ok) { 151 | throw new Error(`API error: ${response.statusText}`); 152 | } 153 | 154 | const reader = response.body?.getReader(); 155 | const decoder = new TextDecoder(); 156 | 157 | if (!reader) { 158 | throw new Error('No response body reader available'); 159 | } 160 | 161 | let buffer = ''; 162 | 163 | try { 164 | while (true) { 165 | const { done, value } = await reader.read(); 166 | 167 | if (done) { 168 | break; 169 | } 170 | 171 | buffer += decoder.decode(value, { stream: true }); 172 | const lines = buffer.split('\n'); 173 | buffer = lines.pop() || ''; 174 | 175 | for (const line of lines) { 176 | if (line.trim() === '' || !line.startsWith('data: ')) continue; 177 | 178 | const data = line.slice(6); 179 | if (data === '[DONE]') continue; 180 | 181 | try { 182 | const event = JSON.parse(data); 183 | 184 | switch (event.type) { 185 | case 'content_block_delta': 186 | if (event.delta?.text) { 187 | streamedContent += event.delta.text; 188 | setMessages(prev => prev.map(msg => 189 | msg.id === tempMessageId 190 | ? { ...msg, content: streamedContent } 191 | : msg 192 | )); 193 | } 194 | break; 195 | 196 | case 'error': 197 | throw new Error(event.error); 198 | } 199 | } catch (e) { 200 | console.error('Error parsing streaming data:', e); 201 | } 202 | } 203 | } 204 | } finally { 205 | reader.releaseLock(); 206 | } 207 | 208 | // Update the final message 209 | setMessages(prev => prev.map(msg => 210 | msg.id === tempMessageId 211 | ? { 212 | role: 'assistant', 213 | content: streamedContent, 214 | suggestedUpdate: extractWorkflowConfig(streamedContent) 215 | } 216 | : msg 217 | )); 218 | 219 | } catch (error) { 220 | console.error("Error processing message:", error); 221 | setMessages(prev => prev.filter(msg => msg.id !== tempMessageId)); 222 | setMessages(prev => [...prev, { 223 | role: "assistant", 224 | content: "I encountered an error while processing your request. Please try again." 225 | }]); 226 | setTimeout(scrollToBottom, 100); 227 | } finally { 228 | setIsProcessing(false); 229 | setIsStreaming(false); 230 | } 231 | }; 232 | 233 | const extractWorkflowConfig = (content: string): { workflow: any; description: string } | undefined => { 234 | try { 235 | // Look for JSON code blocks 236 | const matches = content.match(/```json\n([\s\S]*?)\n```/); 237 | if (!matches) return undefined; 238 | 239 | const jsonStr = matches[1]; 240 | const config = JSON.parse(jsonStr); 241 | 242 | // Extract the description from the text before the JSON 243 | const descriptionMatch = content.match(/Explanation:([\s\S]*?)(?=Step-by-step|```json)/); 244 | const description = descriptionMatch ? descriptionMatch[1].trim() : ""; 245 | 246 | if (config.workflow) { 247 | return { 248 | workflow: config.workflow, 249 | description: description 250 | }; 251 | } 252 | } catch (error) { 253 | console.error("Error extracting workflow config:", error); 254 | } 255 | return undefined; 256 | }; 257 | 258 | const applyWorkflowUpdate = async (update: any) => { 259 | try { 260 | setIsProcessing(true); 261 | 262 | // Create workflow data with only the required properties 263 | const workflowData = { 264 | name: update.name || "New Workflow", 265 | nodes: update.nodes || [], 266 | connections: update.connections || {}, 267 | settings: { 268 | saveExecutionProgress: true, 269 | saveManualExecutions: true, 270 | saveDataErrorExecution: "all", 271 | saveDataSuccessExecution: "all", 272 | timezone: "America/New_York", 273 | ...update.settings 274 | }, 275 | staticData: update.staticData || null 276 | }; 277 | 278 | const response = await client?.createWorkflow(workflowData); 279 | setMessages(prev => [...prev, { 280 | role: "assistant", 281 | content: "✓ Workflow has been created successfully! You can now find it in your n8n instance." 282 | }]); 283 | setTimeout(scrollToBottom, 100); 284 | } catch (error) { 285 | console.error("Error applying workflow update:", error); 286 | setMessages(prev => [...prev, { 287 | role: "assistant", 288 | content: "❌ Failed to create the workflow. Please check your n8n instance configuration and try again." 289 | }]); 290 | setTimeout(scrollToBottom, 100); 291 | } finally { 292 | setIsProcessing(false); 293 | } 294 | }; 295 | 296 | if (!anthropicKey) { 297 | return ( 298 | 299 |

300 | Please add your Anthropic API key in the settings page to enable workflow chat assistance. 301 |

302 |
303 | ); 304 | } 305 | 306 | return ( 307 | 308 | {/* Chat header */} 309 |
310 |

Agent Chat

311 |

312 | Get answers about your current agents or build a new one! 313 |

314 |
315 | 316 | {/* Chat messages */} 317 |
322 | {messages.length === 0 && ( 323 |
324 | Ask me anything about building agents on n8n. I can help you: 325 |
    326 |
  • Design an agent architecture
  • 327 |
  • Set up AI agents workflows
  • 328 |
  • Connect to any internal tools
  • 329 |
  • Handle documents and memory
  • 330 |
331 |
332 | )} 333 | {messages.map((message, index) => ( 334 |
340 |
347 |
348 | {message.content.split('```').map((part, i) => { 349 | if (i % 2 === 0) { 350 | return {part}; 351 | } else { 352 | const [lang, ...code] = part.split('\n'); 353 | const codeContent = code.join('\n'); 354 | return ( 355 |
356 | {lang && ( 357 |
358 | {lang} 359 |
360 | )} 361 |
362 |                           {codeContent || lang}
363 |                         
364 |
365 | ); 366 | } 367 | })} 368 |
369 | {message.suggestedUpdate?.workflow && ( 370 |
371 | 386 | 395 |
396 | )} 397 |
398 |
399 | ))} 400 |
401 |
402 | 403 | {/* Input area */} 404 |
405 |
406 | setInput(e.target.value)} 409 | placeholder="Type your message..." 410 | onKeyPress={(e) => e.key === "Enter" && handleSend()} 411 | disabled={isProcessing} 412 | /> 413 | 419 |
420 |
421 | 422 | ); 423 | } -------------------------------------------------------------------------------- /src/app/dashboard/_components/require-api-config.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { usePathname, useRouter } from "next/navigation"; 5 | import { useN8n } from "@/lib/api/n8n-provider"; 6 | 7 | interface RequireApiConfigProps { 8 | children: React.ReactNode; 9 | } 10 | 11 | export function RequireApiConfig({ children }: RequireApiConfigProps) { 12 | const router = useRouter(); 13 | const pathname = usePathname(); 14 | const { isConfigured } = useN8n(); 15 | 16 | useEffect(() => { 17 | if (!isConfigured && !pathname.includes("/settings")) { 18 | router.push("/dashboard/settings"); 19 | } 20 | }, [isConfigured, router, pathname]); 21 | 22 | if (!isConfigured && !pathname.includes("/settings")) { 23 | return ( 24 |
25 |
26 |

API Not Configured

27 |

28 | Redirecting to settings... 29 |

30 |
31 |
32 | ); 33 | } 34 | 35 | return <>{children}; 36 | } -------------------------------------------------------------------------------- /src/app/dashboard/chat/_components/agent-chat.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Card } from "@/components/ui/card"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useN8n } from "@/lib/api/n8n-provider"; 6 | import { Sparkles, Send, Plus } from "lucide-react"; 7 | 8 | interface Message { 9 | role: "user" | "assistant"; 10 | content: string; 11 | } 12 | 13 | interface AgentCreationStep { 14 | question: string; 15 | key: "goal" | "tools" | "personality"; 16 | } 17 | 18 | const AGENT_CREATION_STEPS: AgentCreationStep[] = [ 19 | { 20 | question: "What is the main goal or purpose of this agent?", 21 | key: "goal", 22 | }, 23 | { 24 | question: "What tools or APIs should the agent have access to? (e.g., web search, calculator, etc.)", 25 | key: "tools", 26 | }, 27 | { 28 | question: "How would you describe the agent's personality or communication style?", 29 | key: "personality", 30 | }, 31 | ]; 32 | 33 | export function AgentChat() { 34 | const { client, anthropicKey } = useN8n(); 35 | const [messages, setMessages] = useState([]); 36 | const [input, setInput] = useState(""); 37 | const [isCreatingAgent, setIsCreatingAgent] = useState(false); 38 | const [currentStep, setCurrentStep] = useState(0); 39 | const [agentConfig, setAgentConfig] = useState>({}); 40 | 41 | const handleSend = async () => { 42 | if (!input.trim()) return; 43 | 44 | const newMessage: Message = { role: "user", content: input }; 45 | setMessages(prev => [...prev, newMessage]); 46 | setInput(""); 47 | 48 | if (isCreatingAgent) { 49 | const step = AGENT_CREATION_STEPS[currentStep]; 50 | setAgentConfig(prev => ({ ...prev, [step.key]: input })); 51 | 52 | if (currentStep < AGENT_CREATION_STEPS.length - 1) { 53 | setCurrentStep(prev => prev + 1); 54 | const nextStep = AGENT_CREATION_STEPS[currentStep + 1]; 55 | setMessages(prev => [...prev, { role: "assistant" as const, content: nextStep.question }]); 56 | } else { 57 | // Create the n8n workflow with an agent node 58 | try { 59 | const workflowData = { 60 | name: `Claude Agent: ${agentConfig.goal}`, 61 | nodes: [ 62 | { 63 | parameters: { 64 | anthropicApiKey: { "__ql": true, "__ql_name": "Anthropic API Key" }, 65 | model: "claude-3-sonnet-20240229", 66 | systemMessage: `You are an AI assistant with the following configuration: 67 | Goal: ${agentConfig.goal} 68 | Tools: ${agentConfig.tools} 69 | Personality: ${agentConfig.personality} 70 | 71 | Please help users achieve their goals while maintaining the specified personality and using the available tools.`, 72 | }, 73 | name: "Claude Agent", 74 | type: "@n8n/n8n-nodes-langchain.agent", 75 | typeVersion: 1, 76 | position: [100, 100], 77 | } 78 | ], 79 | connections: {}, 80 | settings: { 81 | saveExecutionProgress: true, 82 | saveManualExecutions: true, 83 | }, 84 | }; 85 | 86 | const response = await client?.createWorkflow(workflowData); 87 | 88 | setMessages(prev => [ 89 | ...prev, 90 | { 91 | role: "assistant" as const, 92 | content: `I've created a new n8n workflow with a Claude agent! The workflow has been configured with your specifications: 93 | 94 | - Goal: ${agentConfig.goal} 95 | - Tools: ${agentConfig.tools} 96 | - Personality: ${agentConfig.personality} 97 | 98 | You can now find this workflow in your n8n instance and customize it further if needed.` 99 | } 100 | ]); 101 | } catch (error) { 102 | console.error("Failed to create workflow:", error); 103 | setMessages(prev => [ 104 | ...prev, 105 | { 106 | role: "assistant" as const, 107 | content: "I apologize, but I encountered an error while creating the workflow. Please make sure your n8n instance is properly configured and try again." 108 | } 109 | ]); 110 | } 111 | 112 | setIsCreatingAgent(false); 113 | setCurrentStep(0); 114 | setAgentConfig({}); 115 | } 116 | } 117 | }; 118 | 119 | const startAgentCreation = () => { 120 | if (!anthropicKey) { 121 | setMessages([ 122 | { 123 | role: "assistant" as const, 124 | content: "Please add your Anthropic API key in the settings page before creating an agent." 125 | } 126 | ]); 127 | return; 128 | } 129 | 130 | setIsCreatingAgent(true); 131 | setMessages([ 132 | { 133 | role: "assistant" as const, 134 | content: AGENT_CREATION_STEPS[0].question 135 | } 136 | ]); 137 | }; 138 | 139 | return ( 140 | 141 | {/* Chat header */} 142 |
143 |

n8n Agent Chat

144 |
145 | 146 | {/* Chat messages */} 147 |
148 | {messages.map((message, index) => ( 149 |
155 |
162 | {message.content} 163 |
164 |
165 | ))} 166 |
167 | 168 | {/* Quick actions */} 169 | {messages.length === 0 && ( 170 |
171 | 179 |
180 | )} 181 | 182 | {/* Input area */} 183 |
184 |
185 | setInput(e.target.value)} 188 | placeholder={ 189 | isCreatingAgent 190 | ? AGENT_CREATION_STEPS[currentStep].question 191 | : "Type your message..." 192 | } 193 | onKeyPress={(e) => e.key === "Enter" && handleSend()} 194 | /> 195 | 198 |
199 |
200 |
201 | ); 202 | } -------------------------------------------------------------------------------- /src/app/dashboard/chat/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AgentChat } from "./_components/agent-chat"; 4 | 5 | export default function ChatPage() { 6 | return ( 7 |
8 |

n8n Agent Chat

9 |
10 | 11 |
12 |
13 | ); 14 | } -------------------------------------------------------------------------------- /src/app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { RequireApiConfig } from "./_components/require-api-config"; 3 | import { AppSidebar } from "@/components/app-sidebar"; 4 | import { Separator } from "@/components/ui/separator"; 5 | import { 6 | SidebarInset, 7 | SidebarProvider, 8 | SidebarTrigger, 9 | } from "@/components/ui/sidebar"; 10 | 11 | interface DashboardLayoutProps { 12 | children: ReactNode; 13 | } 14 | 15 | export default function DashboardLayout({ children }: DashboardLayoutProps) { 16 | return ( 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 |

Dashboard

25 |
26 |
27 | {children} 28 |
29 |
30 |
31 |
32 | ); 33 | } -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { Card } from "@/components/ui/card"; 5 | import { useN8n } from "@/lib/api/n8n-provider"; 6 | import Link from "next/link"; 7 | import { AgentChat } from "./_components/agent-chat"; 8 | 9 | interface DashboardStats { 10 | totalWorkflows: number; 11 | activeWorkflows: number; 12 | totalExecutions: number; 13 | successRate: number; 14 | } 15 | 16 | interface Workflow { 17 | id: string; 18 | name: string; 19 | active: boolean; 20 | } 21 | 22 | interface N8nResponse { 23 | data: T | { results: T }; 24 | } 25 | 26 | interface WorkflowData { 27 | id: string; 28 | name: string; 29 | active: boolean; 30 | } 31 | 32 | interface ExecutionData { 33 | id: string; 34 | status: string; 35 | } 36 | 37 | export default function DashboardPage() { 38 | const { client } = useN8n(); 39 | const [stats, setStats] = useState({ 40 | totalWorkflows: 0, 41 | activeWorkflows: 0, 42 | totalExecutions: 0, 43 | successRate: 0, 44 | }); 45 | const [workflows, setWorkflows] = useState([]); 46 | const [loading, setLoading] = useState(true); 47 | const [error, setError] = useState(null); 48 | 49 | useEffect(() => { 50 | async function fetchData() { 51 | if (!client) return; 52 | 53 | try { 54 | const [workflowsResponse, executionsResponse] = await Promise.all([ 55 | client.getWorkflows(), 56 | client.getExecutions(), 57 | ]) as [N8nResponse, N8nResponse]; 58 | 59 | console.log("Raw workflows response:", JSON.stringify(workflowsResponse, null, 2)); 60 | console.log("Raw executions response:", JSON.stringify(executionsResponse, null, 2)); 61 | 62 | // Check if we have the expected data structure and handle both array and object responses 63 | const workflowsData = Array.isArray(workflowsResponse.data) 64 | ? workflowsResponse.data 65 | : ('results' in workflowsResponse.data ? workflowsResponse.data.results : []); 66 | 67 | const executionsData = Array.isArray(executionsResponse.data) 68 | ? executionsResponse.data 69 | : ('results' in executionsResponse.data ? executionsResponse.data.results : []); 70 | 71 | console.log("Processed workflows data:", workflowsData); 72 | console.log("Processed executions data:", executionsData); 73 | 74 | const activeWorkflows = workflowsData.filter((w: WorkflowData) => w.active).length; 75 | const successfulExecutions = executionsData.filter( 76 | (e: ExecutionData) => e.status === "success" 77 | ).length; 78 | 79 | setStats({ 80 | totalWorkflows: workflowsData.length, 81 | activeWorkflows, 82 | totalExecutions: executionsData.length, 83 | successRate: 84 | executionsData.length > 0 85 | ? (successfulExecutions / executionsData.length) * 100 86 | : 0, 87 | }); 88 | 89 | // Sort workflows by updatedAt in descending order 90 | const sortedWorkflows = workflowsData 91 | .sort((a: any, b: any) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) 92 | .map((w) => ({ 93 | id: w.id, 94 | name: w.name, 95 | active: w.active, 96 | })); 97 | 98 | setWorkflows(sortedWorkflows); 99 | setError(null); 100 | } catch (error) { 101 | console.error("Failed to fetch dashboard data:", error); 102 | setError(error instanceof Error ? error.message : "Failed to fetch data"); 103 | } finally { 104 | setLoading(false); 105 | } 106 | } 107 | 108 | fetchData(); 109 | }, [client]); 110 | 111 | if (loading) { 112 | return ( 113 |
114 |
Loading...
115 |
116 | ); 117 | } 118 | 119 | if (error) { 120 | return ( 121 |
122 |
123 |

Error

124 |

{error}

125 |
126 |
127 | ); 128 | } 129 | 130 | return ( 131 |
132 |
133 | {/* Stats Section */} 134 |
135 |
136 |

Overview

137 |
138 | 139 |
140 | 141 |

Total Agents

142 |
{stats.totalWorkflows}
143 |
144 | 145 |

Active Agents

146 |
{stats.activeWorkflows}
147 |
148 | 149 |

Total Executions

150 |
{stats.totalExecutions}
151 |
152 | 153 |

Success Rate

154 |
155 | {stats.successRate.toFixed(1)}% 156 |
157 |
158 |
159 | 160 |
161 |

Recent Agents

162 | {workflows.length === 0 ? ( 163 |
No agents found
164 | ) : ( 165 |
166 | {workflows.slice(0, 3).map((workflow) => ( 167 | 172 | 173 |
174 |
175 |

{workflow.name}

176 |

177 | ID: {workflow.id} 178 |

179 |
180 |
187 | {workflow.active ? "Active" : "Inactive"} 188 |
189 |
190 |
191 | 192 | ))} 193 | {workflows.length > 3 && ( 194 | 198 | View all agents → 199 | 200 | )} 201 |
202 | )} 203 |
204 |
205 | 206 | {/* Chat Section */} 207 |
208 |

AI Chat

209 | 210 |
211 |
212 |
213 | ); 214 | } -------------------------------------------------------------------------------- /src/app/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Card } from "@/components/ui/card"; 5 | import { Input } from "@/components/ui/input"; 6 | import { Button } from "@/components/ui/button"; 7 | import { useN8n } from "@/lib/api/n8n-provider"; 8 | import { useRouter } from "next/navigation"; 9 | 10 | export default function SettingsPage() { 11 | const router = useRouter(); 12 | const { configure, clear, isConfigured } = useN8n(); 13 | const [apiKey, setApiKey] = useState(""); 14 | const [baseUrl, setBaseUrl] = useState(""); 15 | const [anthropicKey, setAnthropicKey] = useState(""); 16 | 17 | const handleSubmit = (e: React.FormEvent) => { 18 | e.preventDefault(); 19 | configure(apiKey, baseUrl, anthropicKey); 20 | router.push("/dashboard"); 21 | }; 22 | 23 | return ( 24 |
25 |

Settings

26 | 27 | 28 |

n8n API Configuration

29 | 30 |
31 |
32 | 35 | setApiKey(e.target.value)} 41 | required 42 | /> 43 |

44 | Your API key will be stored securely and used to fetch data from your n8n instance. 45 |

46 |
47 | 48 |
49 | 52 | setBaseUrl(e.target.value)} 58 | required 59 | /> 60 |

61 | The URL of your n8n instance (including http:// or https://) 62 |

63 |
64 | 65 |
66 | 69 | setAnthropicKey(e.target.value)} 75 | required 76 | /> 77 |

78 | Your Anthropic API key is required for creating and interacting with Claude-powered agents. 79 |

80 |
81 | 82 |
83 | 86 | {isConfigured && ( 87 | 99 | )} 100 |
101 |
102 |
103 |
104 | ); 105 | } -------------------------------------------------------------------------------- /src/app/dashboard/workflows/[id]/_components/execution-averages.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@/components/ui/card"; 2 | import { useMemo } from "react"; 3 | 4 | interface ExecutionStep { 5 | startTime?: number; 6 | endTime?: number; 7 | error?: boolean; 8 | executionStatus?: string; 9 | executionTime?: number; 10 | hints?: Array<{ 11 | startTime?: number; 12 | executionTime?: number; 13 | }>; 14 | } 15 | 16 | interface ExecutionDetails { 17 | id: string; 18 | finished: boolean; 19 | mode: string; 20 | startedAt: string; 21 | stoppedAt: string; 22 | status: "success" | "error" | "running" | "waiting"; 23 | data?: { 24 | resultData?: { 25 | runData?: Record; 26 | }; 27 | executionData?: { 28 | resultData?: { 29 | runData?: Record; 30 | }; 31 | }; 32 | }; 33 | resultData?: { 34 | runData?: Record; 35 | }; 36 | executionData?: { 37 | resultData?: { 38 | runData?: Record; 39 | }; 40 | }; 41 | } 42 | 43 | interface ExecutionAveragesProps { 44 | executions: ExecutionDetails[]; 45 | } 46 | 47 | interface NodeStats { 48 | nodeId: string; 49 | avgDuration: number; 50 | successRate: number; 51 | totalExecutions: number; 52 | minDuration: number; 53 | maxDuration: number; 54 | } 55 | 56 | interface NodeStatsWithOffset { 57 | nodeId: string; 58 | avgDuration: number; 59 | successRate: number; 60 | totalExecutions: number; 61 | minDuration: number; 62 | maxDuration: number; 63 | startOffset: number; 64 | } 65 | 66 | export function ExecutionAverages({ executions }: ExecutionAveragesProps) { 67 | const nodeStats = useMemo(() => { 68 | console.log("Processing executions:", executions); 69 | const stats = new Map(); 76 | 77 | executions.forEach(execution => { 78 | console.log("Processing execution:", execution); 79 | const runData = execution.data?.resultData?.runData; 80 | 81 | if (!runData) { 82 | console.log("No runData found for execution"); 83 | return; 84 | } 85 | 86 | Object.entries(runData).forEach(([nodeId, steps]) => { 87 | console.log(`Processing node ${nodeId} with steps:`, steps); 88 | steps.forEach((step: ExecutionStep) => { 89 | let duration: number | undefined; 90 | 91 | if (step.executionTime) { 92 | duration = step.executionTime; 93 | } else if (step.hints && step.hints.length > 0) { 94 | const hint = step.hints[0]; 95 | if (hint.executionTime) { 96 | duration = hint.executionTime; 97 | } else if (hint.startTime) { 98 | const nextHint = step.hints[1]; 99 | if (nextHint && nextHint.startTime) { 100 | duration = nextHint.startTime - hint.startTime; 101 | } 102 | } 103 | } 104 | 105 | if (!duration) { 106 | console.log(`Skipping step for node ${nodeId} - missing timing data:`, step); 107 | return; 108 | } 109 | 110 | const currentStats = stats.get(nodeId) || { 111 | totalDuration: 0, 112 | totalExecutions: 0, 113 | successCount: 0, 114 | minDuration: Infinity, 115 | maxDuration: -Infinity, 116 | }; 117 | 118 | currentStats.totalDuration += duration; 119 | currentStats.totalExecutions += 1; 120 | 121 | const isSuccess = step.executionStatus === 'success' || 122 | (!step.error && !step.executionStatus); 123 | if (isSuccess) currentStats.successCount += 1; 124 | 125 | currentStats.minDuration = Math.min(currentStats.minDuration, duration); 126 | currentStats.maxDuration = Math.max(currentStats.maxDuration, duration); 127 | 128 | stats.set(nodeId, currentStats); 129 | }); 130 | }); 131 | }); 132 | 133 | const nodeStats: NodeStats[] = Array.from(stats.entries()).map(([nodeId, stats]) => ({ 134 | nodeId, 135 | avgDuration: stats.totalDuration / stats.totalExecutions, 136 | successRate: (stats.successCount / stats.totalExecutions) * 100, 137 | totalExecutions: stats.totalExecutions, 138 | minDuration: stats.minDuration === Infinity ? 0 : stats.minDuration, 139 | maxDuration: stats.maxDuration === -Infinity ? 0 : stats.maxDuration, 140 | })); 141 | 142 | console.log("Final node stats:", nodeStats); 143 | return nodeStats; 144 | }, [executions]); 145 | 146 | // Simplified waterfall calculation 147 | const waterfallStats = useMemo(() => { 148 | if (!nodeStats || nodeStats.length === 0) { 149 | console.log("No node stats available"); 150 | return []; 151 | } 152 | 153 | console.log("Calculating waterfall stats"); 154 | let currentOffset = 0; 155 | const stats: NodeStatsWithOffset[] = []; 156 | 157 | // Sort nodes by their first appearance in executions 158 | const nodeOrder = new Map(); 159 | executions.forEach(execution => { 160 | const runData = execution.data?.resultData?.runData; 161 | if (!runData) return; 162 | 163 | Object.keys(runData).forEach((nodeId, index) => { 164 | if (!nodeOrder.has(nodeId)) { 165 | nodeOrder.set(nodeId, index); 166 | } 167 | }); 168 | }); 169 | 170 | // Sort nodeStats based on execution order 171 | const sortedNodes = [...nodeStats].sort((a, b) => { 172 | const orderA = nodeOrder.get(a.nodeId) ?? Infinity; 173 | const orderB = nodeOrder.get(b.nodeId) ?? Infinity; 174 | return orderA - orderB; 175 | }); 176 | 177 | // Calculate offsets 178 | sortedNodes.forEach(node => { 179 | stats.push({ 180 | ...node, 181 | startOffset: currentOffset, 182 | }); 183 | currentOffset += node.avgDuration; 184 | }); 185 | 186 | console.log("Waterfall stats calculated:", stats); 187 | return stats; 188 | }, [nodeStats, executions]); 189 | 190 | if (!executions || executions.length === 0) { 191 | return ( 192 |
193 | No execution data available 194 |
195 | ); 196 | } 197 | 198 | if (!waterfallStats || waterfallStats.length === 0) { 199 | return ( 200 |
201 | No node statistics available 202 |
203 | ); 204 | } 205 | 206 | const totalDuration = Math.max(...waterfallStats.map(stat => stat.startOffset + stat.avgDuration)); 207 | 208 | return ( 209 | 210 |

Workflow Execution Waterfall

211 | 212 |
213 | {waterfallStats.map((stat) => { 214 | const startPercentage = (stat.startOffset / totalDuration) * 100; 215 | const widthPercentage = (stat.avgDuration / totalDuration) * 100; 216 | 217 | return ( 218 |
219 | {/* Node name and stats */} 220 |
221 |
{stat.nodeId}
222 |
223 | {stat.totalExecutions} executions 224 |
225 |
226 | 227 | {/* Timeline bar */} 228 |
229 | {/* Background track */} 230 |
231 | {/* Start offset */} 232 |
236 | {/* Average duration bar */} 237 |
90 240 | ? "bg-green-400" 241 | : stat.successRate > 70 242 | ? "bg-yellow-400" 243 | : "bg-red-400" 244 | }`} 245 | style={{ 246 | left: `${startPercentage}%`, 247 | width: `${widthPercentage}%` 248 | }} 249 | /> 250 |
251 | 252 | {/* Min-max range */} 253 |
260 | 261 | {/* Tooltip */} 262 |
263 |
Node: {stat.nodeId}
264 |
Average: {(stat.avgDuration / 1000).toFixed(2)}s
265 |
Start Offset: {(stat.startOffset / 1000).toFixed(2)}s
266 |
Range: {(stat.minDuration / 1000).toFixed(2)}s - {(stat.maxDuration / 1000).toFixed(2)}s
267 |
Success Rate: {stat.successRate.toFixed(1)}%
268 |
Total Executions: {stat.totalExecutions}
269 |
270 |
271 |
272 | ); 273 | })} 274 |
275 | 276 | {/* Time scale */} 277 |
278 | 0s 279 | {(totalDuration / 1000).toFixed(2)}s 280 |
281 | 282 | {/* Legend */} 283 |
284 |
285 |
286 | >90% Success 287 |
288 |
289 |
290 | 70-90% Success 291 |
292 |
293 |
294 | <70% Success 295 |
296 |
297 |
298 | Wait Time 299 |
300 |
301 |
302 | Min-Max Range 303 |
304 |
305 | 306 | ); 307 | } -------------------------------------------------------------------------------- /src/app/dashboard/workflows/[id]/_components/execution-waterfall.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | interface NodeExecution { 4 | node: string; 5 | startTime: number; 6 | endTime?: number; 7 | status: "success" | "error" | "running"; 8 | duration: number; 9 | } 10 | 11 | interface ExecutionWaterfallProps { 12 | data: Record; 13 | executionStartTime: string; 14 | } 15 | 16 | export function ExecutionWaterfall({ data, executionStartTime }: ExecutionWaterfallProps) { 17 | const nodeExecutions = useMemo(() => { 18 | const executions: NodeExecution[] = []; 19 | const startTimeMs = new Date(executionStartTime).getTime(); 20 | 21 | console.log("Processing execution data:", data); 22 | 23 | if (!data || Object.keys(data).length === 0) { 24 | console.log("No execution data to process"); 25 | return executions; 26 | } 27 | 28 | Object.entries(data).forEach(([nodeId, steps]) => { 29 | if (!Array.isArray(steps)) { 30 | console.log(`Skipping node ${nodeId} - steps is not an array:`, steps); 31 | return; 32 | } 33 | 34 | steps.forEach((step: any) => { 35 | console.log(`Processing step for node ${nodeId}:`, step); 36 | 37 | // Get timing information from either the step itself or its hints 38 | let stepStartTime = startTimeMs; 39 | let stepEndTime: number | undefined; 40 | let duration: number | undefined; 41 | 42 | if (step.executionTime) { 43 | // If we have executionTime, use that for duration 44 | duration = step.executionTime; 45 | if (step.startTime) stepStartTime = step.startTime; 46 | stepEndTime = stepStartTime + (duration as number); 47 | } else if (step.hints && step.hints.length > 0) { 48 | // If we have hints with timing information, use those 49 | const hint = step.hints[0]; 50 | if (hint.executionTime) { 51 | duration = hint.executionTime; 52 | if (hint.startTime) stepStartTime = hint.startTime; 53 | stepEndTime = stepStartTime + (duration as number); 54 | } else if (hint.startTime) { 55 | stepStartTime = hint.startTime; 56 | // Look for end time in next hint 57 | const nextHint = step.hints[1]; 58 | if (nextHint && nextHint.startTime) { 59 | stepEndTime = nextHint.startTime; 60 | duration = (stepEndTime as number) - stepStartTime; 61 | } 62 | } 63 | } 64 | 65 | // If we still don't have timing info, try direct properties 66 | if (step.startTime) { 67 | stepStartTime = step.startTime; 68 | } 69 | if (!stepEndTime && step.endTime) { 70 | stepEndTime = step.endTime; 71 | duration = (stepEndTime as number) - stepStartTime; 72 | } 73 | 74 | // Skip if we don't have enough timing information 75 | if (!duration && !stepEndTime) { 76 | console.log(`Skipping step for node ${nodeId} - insufficient timing data`); 77 | return; 78 | } 79 | 80 | // Calculate duration if we don't have it yet 81 | if (!duration && stepEndTime) { 82 | duration = (stepEndTime as number) - stepStartTime; 83 | } 84 | 85 | // Determine status 86 | let status: "success" | "error" | "running" = "running"; 87 | if (step.error || step.executionStatus === "error") { 88 | status = "error"; 89 | } else if (stepEndTime || step.executionStatus === "success") { 90 | status = "success"; 91 | } 92 | 93 | executions.push({ 94 | node: nodeId, 95 | startTime: stepStartTime, 96 | endTime: stepEndTime, 97 | status, 98 | duration: duration || 0, 99 | }); 100 | }); 101 | }); 102 | 103 | // Sort by start time 104 | return executions.sort((a, b) => a.startTime - b.startTime); 105 | }, [data, executionStartTime]); 106 | 107 | if (nodeExecutions.length === 0) { 108 | return ( 109 |
110 | No execution data available 111 |
112 | ); 113 | } 114 | 115 | const timelineStart = Math.min(...nodeExecutions.map(exec => exec.startTime)); 116 | const timelineEnd = Math.max(...nodeExecutions.map(exec => 117 | exec.endTime || (exec.startTime + exec.duration) 118 | )); 119 | const timelineDuration = timelineEnd - timelineStart; 120 | 121 | return ( 122 |
123 | {/* Timeline header */} 124 |
125 | 0ms 126 | {Math.round(timelineDuration)}ms 127 |
128 | 129 | {/* Waterfall bars */} 130 |
131 | {nodeExecutions.map((execution, index) => { 132 | const startOffset = ((execution.startTime - timelineStart) / timelineDuration) * 100; 133 | const width = (execution.duration / timelineDuration) * 100; 134 | 135 | return ( 136 |
137 | {/* Node name */} 138 |
139 | {execution.node} 140 |
141 | 142 | {/* Timeline bar */} 143 |
144 | {/* Execution bar */} 145 |
158 | {/* Duration tooltip */} 159 |
160 |
Node: {execution.node}
161 |
Start: {new Date(execution.startTime).toISOString()}
162 | {execution.endTime && ( 163 |
End: {new Date(execution.endTime).toISOString()}
164 | )} 165 |
Duration: {execution.duration}ms
166 |
Status: {execution.status}
167 |
168 |
169 |
170 |
171 | ); 172 | })} 173 |
174 |
175 | ); 176 | } -------------------------------------------------------------------------------- /src/app/dashboard/workflows/[id]/_components/workflow-timeline.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@/components/ui/card"; 2 | 3 | interface Execution { 4 | id: string; 5 | startedAt: string; 6 | stoppedAt?: string; 7 | status: string; 8 | } 9 | 10 | interface WorkflowTimelineProps { 11 | executions: Execution[]; 12 | } 13 | 14 | export function WorkflowTimeline({ executions }: WorkflowTimelineProps) { 15 | if (executions.length === 0) { 16 | return ( 17 |
18 | No executions available 19 |
20 | ); 21 | } 22 | 23 | // Sort executions by start time 24 | const sortedExecutions = [...executions].sort( 25 | (a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime() 26 | ); 27 | 28 | // Calculate timeline scale 29 | const startTime = new Date(sortedExecutions[0].startedAt).getTime(); 30 | const endTime = Math.max( 31 | ...sortedExecutions.map(exec => 32 | exec.stoppedAt 33 | ? new Date(exec.stoppedAt).getTime() 34 | : new Date(exec.startedAt).getTime() 35 | ) 36 | ); 37 | const timelineDuration = endTime - startTime; 38 | 39 | return ( 40 | 41 |

Execution Timeline

42 | 43 | {/* Timeline header */} 44 |
45 | {new Date(startTime).toLocaleString()} 46 | {new Date(endTime).toLocaleString()} 47 |
48 | 49 | {/* Timeline bars */} 50 |
51 | {sortedExecutions.map((execution) => { 52 | const startOffset = ((new Date(execution.startedAt).getTime() - startTime) / timelineDuration) * 100; 53 | const duration = execution.stoppedAt 54 | ? new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime() 55 | : 0; 56 | const width = duration ? (duration / timelineDuration) * 100 : 2; 57 | 58 | return ( 59 |
60 | {/* Execution ID */} 61 |
62 | #{execution.id} 63 |
64 | 65 | {/* Timeline bar */} 66 |
67 | {/* Execution bar */} 68 |
81 | {/* Tooltip */} 82 |
83 |
Execution #{execution.id}
84 |
Started: {new Date(execution.startedAt).toLocaleString()}
85 | {execution.stoppedAt && ( 86 | <> 87 |
Ended: {new Date(execution.stoppedAt).toLocaleString()}
88 |
Duration: {(duration / 1000).toFixed(2)}s
89 | 90 | )} 91 |
Status: {execution.status}
92 |
93 |
94 |
95 |
96 | ); 97 | })} 98 |
99 | 100 | {/* Legend */} 101 |
102 |
103 |
104 | Success 105 |
106 |
107 |
108 | Error 109 |
110 |
111 |
112 | Running 113 |
114 |
115 |
116 | ); 117 | } -------------------------------------------------------------------------------- /src/app/dashboard/workflows/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState, useMemo } from "react"; 4 | import { useParams } from "next/navigation"; 5 | import { useN8n } from "@/lib/api/n8n-provider"; 6 | import { Card } from "@/components/ui/card"; 7 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 8 | import { ExecutionWaterfall } from "./_components/execution-waterfall"; 9 | import { WorkflowStats } from "../_components/workflow-stats"; 10 | import { ExecutionAverages } from "./_components/execution-averages"; 11 | import { Button } from "@/components/ui/button"; 12 | import { ExternalLink } from "lucide-react"; 13 | import { WorkflowChat } from "./_components/workflow-chat"; 14 | 15 | interface WorkflowDetails { 16 | id: string; 17 | name: string; 18 | active: boolean; 19 | createdAt: string; 20 | updatedAt: string; 21 | } 22 | 23 | interface ExecutionStep { 24 | startTime?: number; 25 | endTime?: number; 26 | error?: boolean; 27 | executionStatus?: string; 28 | hints?: Array<{ 29 | startTime?: number; 30 | executionTime?: number; 31 | }>; 32 | } 33 | 34 | interface ExecutionDetails { 35 | id: string; 36 | finished: boolean; 37 | mode: string; 38 | startedAt: string; 39 | stoppedAt: string; 40 | status: "success" | "error" | "running" | "waiting"; 41 | data?: { 42 | resultData?: { 43 | runData?: Record; 44 | }; 45 | executionData?: { 46 | resultData?: { 47 | runData?: Record; 48 | }; 49 | }; 50 | }; 51 | resultData?: { 52 | runData?: Record; 53 | }; 54 | executionData?: { 55 | resultData?: { 56 | runData?: Record; 57 | }; 58 | }; 59 | } 60 | 61 | export default function WorkflowDetailsPage() { 62 | const params = useParams(); 63 | const { client } = useN8n(); 64 | const [workflow, setWorkflow] = useState(null); 65 | const [executions, setExecutions] = useState([]); 66 | const [selectedExecution, setSelectedExecution] = useState(null); 67 | const [selectedTab, setSelectedTab] = useState("executions"); 68 | const [loading, setLoading] = useState(true); 69 | const [error, setError] = useState(null); 70 | 71 | useEffect(() => { 72 | async function fetchData() { 73 | if (!client) return; 74 | 75 | try { 76 | const [workflowResponse, executionsResponse] = await Promise.all([ 77 | client.getWorkflow(params.id as string), 78 | client.getExecutions(params.id as string), 79 | ]); 80 | 81 | console.log("Workflow response:", workflowResponse); 82 | console.log("Executions response:", executionsResponse); 83 | 84 | setWorkflow(workflowResponse.data); 85 | setExecutions(executionsResponse.data || []); 86 | setError(null); 87 | } catch (error) { 88 | console.error("Failed to fetch workflow data:", error); 89 | setError(error instanceof Error ? error.message : "Failed to fetch data"); 90 | } finally { 91 | setLoading(false); 92 | } 93 | } 94 | 95 | fetchData(); 96 | }, [client, params.id]); 97 | 98 | const fetchExecutionDetails = async (executionId: string) => { 99 | if (!client) return; 100 | 101 | try { 102 | const response = await client.getExecution(executionId); 103 | console.log("Execution details response:", response); 104 | setSelectedExecution(response.data); 105 | setSelectedTab("details"); 106 | } catch (error) { 107 | console.error("Failed to fetch execution details:", error); 108 | setError(error instanceof Error ? error.message : "Failed to fetch execution details"); 109 | } 110 | }; 111 | 112 | const hasExecutionSteps = useMemo(() => { 113 | if (!selectedExecution) return false; 114 | 115 | // Check all possible data structures 116 | const runData = selectedExecution.data?.resultData?.runData || 117 | selectedExecution.data?.executionData?.resultData?.runData || 118 | selectedExecution.resultData?.runData || 119 | selectedExecution.executionData?.resultData?.runData; 120 | 121 | console.log("Run data found:", runData); 122 | return runData && Object.keys(runData).length > 0; 123 | }, [selectedExecution]); 124 | 125 | // Calculate execution statistics 126 | const finishedExecutions = executions.filter(e => e.finished); 127 | const totalExecutions = executions.length; 128 | const successfulExecutions = executions.filter(e => e.status === "success").length; 129 | const failedExecutions = executions.filter(e => e.status === "error").length; 130 | const lastExecution = executions[0]; // Assuming executions are sorted by date 131 | 132 | // Calculate average execution time 133 | const avgExecutionTime = finishedExecutions.reduce((acc, execution) => { 134 | if (execution.startedAt && execution.stoppedAt) { 135 | const duration = new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(); 136 | return acc + duration; 137 | } 138 | return acc; 139 | }, 0) / (finishedExecutions.length || 1); 140 | 141 | if (loading) { 142 | return ( 143 |
144 |
Loading...
145 |
146 | ); 147 | } 148 | 149 | if (error) { 150 | return ( 151 |
152 |
153 |

Error

154 |

{error}

155 |
156 |
157 | ); 158 | } 159 | 160 | console.log("Selected execution:", selectedExecution); 161 | console.log("Has execution steps:", hasExecutionSteps); 162 | 163 | return ( 164 |
165 | {/* Agent Header */} 166 |
167 |
168 |

{workflow?.name}

169 |

ID: {workflow?.id}

170 |
171 |
172 | 181 |
188 | {workflow?.active ? "Active" : "Inactive"} 189 |
190 |
191 |
192 | 193 | {/* Main Content Grid */} 194 |
195 | {/* Left Column - Existing Content */} 196 |
197 | {/* Agent Details */} 198 | 199 |
200 |
201 |

Created

202 |

{new Date(workflow?.createdAt || "").toLocaleString()}

203 |
204 |
205 |

Last Updated

206 |

{new Date(workflow?.updatedAt || "").toLocaleString()}

207 |
208 |
209 |
210 | 211 | {/* Execution Statistics */} 212 | 219 | 220 | {/* Execution Averages */} 221 | 222 |
223 | 224 | {/* Right Column - Chat and Executions */} 225 |
226 | {/* Agent Chat */} 227 | {workflow && ( 228 | 232 | )} 233 | 234 | {/* Executions List and Details */} 235 | 236 | 237 | 238 | Executions 239 | 240 | Execution Details 241 | 242 | 243 | 244 | 245 | {executions.length === 0 ? ( 246 |
247 | No executions found for this agent 248 |
249 | ) : ( 250 | executions.map((execution) => ( 251 | fetchExecutionDetails(execution.id)} 255 | > 256 |
257 |
258 |

Execution {execution.id}

259 |

260 | Started: {new Date(execution.startedAt).toLocaleString()} 261 |

262 |
263 |
274 | {execution.status} 275 |
276 |
277 |
278 | )) 279 | )} 280 |
281 | 282 | 283 | {selectedExecution && hasExecutionSteps && ( 284 |
285 | 292 |
293 | )} 294 |
295 |
296 |
297 |
298 |
299 |
300 | ); 301 | } -------------------------------------------------------------------------------- /src/app/dashboard/workflows/_components/workflow-stats.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@/components/ui/card"; 2 | 3 | interface WorkflowStatsProps { 4 | totalExecutions: number; 5 | successfulExecutions: number; 6 | failedExecutions: number; 7 | avgExecutionTime: number; 8 | lastExecution?: { 9 | status: string; 10 | startedAt: string; 11 | }; 12 | } 13 | 14 | export function WorkflowStats({ 15 | totalExecutions, 16 | successfulExecutions, 17 | failedExecutions, 18 | avgExecutionTime, 19 | lastExecution, 20 | }: WorkflowStatsProps) { 21 | const successRate = totalExecutions > 0 22 | ? ((successfulExecutions / totalExecutions) * 100).toFixed(1) 23 | : "0.0"; 24 | 25 | return ( 26 |
27 | 28 |

Total Executions

29 |
{totalExecutions}
30 |
31 | 32 |

Success Rate

33 |
{successRate}%
34 |
35 | {successfulExecutions} successful 36 |
37 |
38 | 39 |

Failed Executions

40 |
{failedExecutions}
41 |
42 | {((failedExecutions / totalExecutions) * 100).toFixed(1)}% failure rate 43 |
44 |
45 | 46 |

Avg Execution Time

47 |
48 | {avgExecutionTime > 1000 49 | ? `${(avgExecutionTime / 1000).toFixed(2)}s` 50 | : `${Math.round(avgExecutionTime)}ms`} 51 |
52 |
53 | 54 |

Last Execution

55 | {lastExecution ? ( 56 |
57 |
58 | 67 | {lastExecution.status} 68 | 69 |
70 |
71 | {new Date(lastExecution.startedAt).toLocaleString()} 72 |
73 |
74 | ) : ( 75 |
No executions yet
76 | )} 77 |
78 |
79 | ); 80 | } -------------------------------------------------------------------------------- /src/app/dashboard/workflows/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useN8n } from "@/lib/api/n8n-provider"; 5 | import { Card } from "@/components/ui/card"; 6 | import Link from "next/link"; 7 | import { WorkflowStats } from "./_components/workflow-stats"; 8 | 9 | interface Workflow { 10 | id: string; 11 | name: string; 12 | active: boolean; 13 | } 14 | 15 | interface Execution { 16 | id: string; 17 | status: string; 18 | startedAt: string; 19 | stoppedAt?: string; 20 | workflowId: string; 21 | finished: boolean; 22 | } 23 | 24 | export default function WorkflowsPage() { 25 | const { client } = useN8n(); 26 | const [workflows, setWorkflows] = useState([]); 27 | const [executions, setExecutions] = useState([]); 28 | const [loading, setLoading] = useState(true); 29 | const [error, setError] = useState(null); 30 | 31 | useEffect(() => { 32 | async function fetchData() { 33 | if (!client) return; 34 | 35 | try { 36 | const [workflowsResponse, executionsResponse] = await Promise.all([ 37 | client.getWorkflows(), 38 | client.getExecutions(), 39 | ]); 40 | 41 | console.log("Workflows response:", workflowsResponse); 42 | console.log("Executions response:", executionsResponse); 43 | 44 | // Sort workflows by updatedAt in descending order 45 | const sortedWorkflows = (workflowsResponse.data || []) 46 | .sort((a: any, b: any) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); 47 | 48 | setWorkflows(sortedWorkflows); 49 | setExecutions(executionsResponse.data || []); 50 | setError(null); 51 | } catch (error) { 52 | console.error("Failed to fetch data:", error); 53 | setError(error instanceof Error ? error.message : "Failed to fetch data"); 54 | } finally { 55 | setLoading(false); 56 | } 57 | } 58 | 59 | fetchData(); 60 | }, [client]); 61 | 62 | if (loading) { 63 | return ( 64 |
65 |
Loading...
66 |
67 | ); 68 | } 69 | 70 | if (error) { 71 | return ( 72 |
73 |
74 |

Error

75 |

{error}

76 |
77 |
78 | ); 79 | } 80 | 81 | // Calculate execution statistics 82 | const finishedExecutions = executions.filter(e => e.finished); 83 | const totalExecutions = executions.length; 84 | const successfulExecutions = executions.filter(e => e.status === "success").length; 85 | const failedExecutions = executions.filter(e => e.status === "error").length; 86 | const lastExecution = executions[0]; // Assuming executions are sorted by date 87 | 88 | // Calculate average execution time 89 | const avgExecutionTime = finishedExecutions.reduce((acc, execution) => { 90 | if (execution.startedAt && execution.stoppedAt) { 91 | const duration = new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(); 92 | return acc + duration; 93 | } 94 | return acc; 95 | }, 0) / (finishedExecutions.length || 1); 96 | 97 | // Calculate per-workflow statistics 98 | const workflowStats = workflows.map(workflow => { 99 | const workflowExecutions = executions.filter(e => e.workflowId === workflow.id); 100 | const workflowFinishedExecutions = workflowExecutions.filter(e => e.finished); 101 | const successCount = workflowExecutions.filter(e => e.status === "success").length; 102 | const failureCount = workflowExecutions.filter(e => e.status === "error").length; 103 | 104 | const avgTime = workflowFinishedExecutions.reduce((acc, execution) => { 105 | if (execution.startedAt && execution.stoppedAt) { 106 | const duration = new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(); 107 | return acc + duration; 108 | } 109 | return acc; 110 | }, 0) / (workflowFinishedExecutions.length || 1); 111 | 112 | return { 113 | ...workflow, 114 | totalExecutions: workflowExecutions.length, 115 | successCount, 116 | failureCount, 117 | avgExecutionTime: avgTime, 118 | }; 119 | }); 120 | 121 | return ( 122 |
123 |
124 |

Agents

125 |
126 | Total Agents: {workflows.length} 127 |
128 |
129 | 130 | {/* Execution Statistics */} 131 | 138 | 139 | {/* Agents List */} 140 |
141 | {workflowStats.length === 0 ? ( 142 |
143 | No agents found 144 |
145 | ) : ( 146 | workflowStats.map((workflow) => ( 147 | 152 | 153 |
154 |
155 |

{workflow.name}

156 |

157 | ID: {workflow.id} 158 |

159 |
160 |
167 | {workflow.active ? "Active" : "Inactive"} 168 |
169 |
170 | 171 | {/* Agent-specific stats */} 172 |
173 |
174 | 175 | {workflow.totalExecutions} 176 | {" "} 177 | executions 178 |
179 |
180 | 181 | {workflow.successCount} 182 | {" "} 183 | successful 184 |
185 |
186 | 187 | {workflow.failureCount} 188 | {" "} 189 | failed 190 |
191 |
192 | 193 | {workflow.avgExecutionTime > 1000 194 | ? `${(workflow.avgExecutionTime / 1000).toFixed(2)}s` 195 | : `${Math.round(workflow.avgExecutionTime)}ms`} 196 | {" "} 197 | avg time 198 |
199 |
200 |
201 | 202 | )) 203 | )} 204 |
205 |
206 | ); 207 | } -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rforgeon/AgentRails/6e55872de165cd9e49c23d9a4736785cf7476e81/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 0 0% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 0 0% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 0 0% 3.9%; 17 | --primary: 0 0% 9%; 18 | --primary-foreground: 0 0% 98%; 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | --muted: 0 0% 96.1%; 22 | --muted-foreground: 0 0% 45.1%; 23 | --accent: 0 0% 96.1%; 24 | --accent-foreground: 0 0% 9%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 0 0% 98%; 27 | --border: 0 0% 89.8%; 28 | --input: 0 0% 89.8%; 29 | --ring: 0 0% 3.9%; 30 | --chart-1: 12 76% 61%; 31 | --chart-2: 173 58% 39%; 32 | --chart-3: 197 37% 24%; 33 | --chart-4: 43 74% 66%; 34 | --chart-5: 27 87% 67%; 35 | --radius: 0.5rem; 36 | --sidebar-background: 0 0% 98%; 37 | --sidebar-foreground: 240 5.3% 26.1%; 38 | --sidebar-primary: 240 5.9% 10%; 39 | --sidebar-primary-foreground: 0 0% 98%; 40 | --sidebar-accent: 240 4.8% 95.9%; 41 | --sidebar-accent-foreground: 240 5.9% 10%; 42 | --sidebar-border: 220 13% 91%; 43 | --sidebar-ring: 217.2 91.2% 59.8%; 44 | } 45 | .dark { 46 | --background: 0 0% 3.9%; 47 | --foreground: 0 0% 98%; 48 | --card: 0 0% 3.9%; 49 | --card-foreground: 0 0% 98%; 50 | --popover: 0 0% 3.9%; 51 | --popover-foreground: 0 0% 98%; 52 | --primary: 0 0% 98%; 53 | --primary-foreground: 0 0% 9%; 54 | --secondary: 0 0% 14.9%; 55 | --secondary-foreground: 0 0% 98%; 56 | --muted: 0 0% 14.9%; 57 | --muted-foreground: 0 0% 63.9%; 58 | --accent: 0 0% 14.9%; 59 | --accent-foreground: 0 0% 98%; 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | --border: 0 0% 14.9%; 63 | --input: 0 0% 14.9%; 64 | --ring: 0 0% 83.1%; 65 | --chart-1: 220 70% 50%; 66 | --chart-2: 160 60% 45%; 67 | --chart-3: 30 80% 55%; 68 | --chart-4: 280 65% 60%; 69 | --chart-5: 340 75% 55%; 70 | --sidebar-background: 240 5.9% 10%; 71 | --sidebar-foreground: 240 4.8% 95.9%; 72 | --sidebar-primary: 224.3 76.3% 48%; 73 | --sidebar-primary-foreground: 0 0% 100%; 74 | --sidebar-accent: 240 3.7% 15.9%; 75 | --sidebar-accent-foreground: 240 4.8% 95.9%; 76 | --sidebar-border: 240 3.7% 15.9%; 77 | --sidebar-ring: 217.2 91.2% 59.8%; 78 | } 79 | } 80 | 81 | @layer base { 82 | * { 83 | @apply border-border; 84 | } 85 | body { 86 | @apply bg-background text-foreground; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { N8nProvider } from "@/lib/api/n8n-provider"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: "Dashboard", 10 | description: "A dashboard for monitoring and managing your agents", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default function Home() { 4 | redirect("/dashboard"); 5 | } 6 | -------------------------------------------------------------------------------- /src/components/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { 5 | AudioWaveform, 6 | BookOpen, 7 | Bot, 8 | Command, 9 | Frame, 10 | GalleryVerticalEnd, 11 | Map, 12 | PieChart, 13 | Settings2, 14 | SquareTerminal, 15 | LayoutDashboard, 16 | Settings, 17 | GitBranch, 18 | } from "lucide-react" 19 | import Link from "next/link" 20 | import { usePathname } from "next/navigation" 21 | import { cn } from "@/lib/utils" 22 | import { Button } from "@/components/ui/button" 23 | import { 24 | Sidebar, 25 | SidebarContent, 26 | SidebarFooter, 27 | SidebarHeader, 28 | SidebarRail, 29 | } from "@/components/ui/sidebar" 30 | 31 | // This is sample data. 32 | const data = { 33 | user: { 34 | name: "shadcn", 35 | email: "m@example.com", 36 | avatar: "/avatars/shadcn.jpg", 37 | }, 38 | teams: [ 39 | { 40 | name: "Acme Inc", 41 | logo: GalleryVerticalEnd, 42 | plan: "Enterprise", 43 | }, 44 | { 45 | name: "Acme Corp.", 46 | logo: AudioWaveform, 47 | plan: "Startup", 48 | }, 49 | { 50 | name: "Evil Corp.", 51 | logo: Command, 52 | plan: "Free", 53 | }, 54 | ], 55 | navMain: [ 56 | { 57 | title: "Playground", 58 | url: "#", 59 | icon: SquareTerminal, 60 | isActive: true, 61 | items: [ 62 | { 63 | title: "History", 64 | url: "#", 65 | }, 66 | { 67 | title: "Starred", 68 | url: "#", 69 | }, 70 | { 71 | title: "Settings", 72 | url: "#", 73 | }, 74 | ], 75 | }, 76 | { 77 | title: "Models", 78 | url: "#", 79 | icon: Bot, 80 | items: [ 81 | { 82 | title: "Genesis", 83 | url: "#", 84 | }, 85 | { 86 | title: "Explorer", 87 | url: "#", 88 | }, 89 | { 90 | title: "Quantum", 91 | url: "#", 92 | }, 93 | ], 94 | }, 95 | { 96 | title: "Documentation", 97 | url: "#", 98 | icon: BookOpen, 99 | items: [ 100 | { 101 | title: "Introduction", 102 | url: "#", 103 | }, 104 | { 105 | title: "Get Started", 106 | url: "#", 107 | }, 108 | { 109 | title: "Tutorials", 110 | url: "#", 111 | }, 112 | { 113 | title: "Changelog", 114 | url: "#", 115 | }, 116 | ], 117 | }, 118 | { 119 | title: "Settings", 120 | url: "#", 121 | icon: Settings2, 122 | items: [ 123 | { 124 | title: "General", 125 | url: "#", 126 | }, 127 | { 128 | title: "Team", 129 | url: "#", 130 | }, 131 | { 132 | title: "Billing", 133 | url: "#", 134 | }, 135 | { 136 | title: "Limits", 137 | url: "#", 138 | }, 139 | ], 140 | }, 141 | ], 142 | projects: [ 143 | { 144 | name: "Design Engineering", 145 | url: "#", 146 | icon: Frame, 147 | }, 148 | { 149 | name: "Sales & Marketing", 150 | url: "#", 151 | icon: PieChart, 152 | }, 153 | { 154 | name: "Travel", 155 | url: "#", 156 | icon: Map, 157 | }, 158 | ], 159 | } 160 | 161 | const routes = [ 162 | { 163 | label: "Dashboard", 164 | icon: LayoutDashboard, 165 | href: "/dashboard", 166 | }, 167 | { 168 | label: "Agents", 169 | icon: GitBranch, 170 | href: "/dashboard/workflows", 171 | }, 172 | { 173 | label: "Settings", 174 | icon: Settings, 175 | href: "/dashboard/settings", 176 | }, 177 | ] 178 | 179 | export function AppSidebar() { 180 | const pathname = usePathname() 181 | 182 | return ( 183 | 184 |
185 |
186 |

Dashboard

187 |
188 |
189 | 205 |
206 |
207 |
208 | ) 209 | } 210 | -------------------------------------------------------------------------------- /src/components/nav-main.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ChevronRight, type LucideIcon } from "lucide-react" 4 | 5 | import { 6 | Collapsible, 7 | CollapsibleContent, 8 | CollapsibleTrigger, 9 | } from "@/components/ui/collapsible" 10 | import { 11 | SidebarGroup, 12 | SidebarGroupLabel, 13 | SidebarMenu, 14 | SidebarMenuButton, 15 | SidebarMenuItem, 16 | SidebarMenuSub, 17 | SidebarMenuSubButton, 18 | SidebarMenuSubItem, 19 | } from "@/components/ui/sidebar" 20 | 21 | export function NavMain({ 22 | items, 23 | }: { 24 | items: { 25 | title: string 26 | url: string 27 | icon?: LucideIcon 28 | isActive?: boolean 29 | items?: { 30 | title: string 31 | url: string 32 | }[] 33 | }[] 34 | }) { 35 | return ( 36 | 37 | Platform 38 | 39 | {items.map((item) => ( 40 | 46 | 47 | 48 | 49 | {item.icon && } 50 | {item.title} 51 | 52 | 53 | 54 | 55 | 56 | {item.items?.map((subItem) => ( 57 | 58 | 59 | 60 | {subItem.title} 61 | 62 | 63 | 64 | ))} 65 | 66 | 67 | 68 | 69 | ))} 70 | 71 | 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /src/components/nav-projects.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Folder, 5 | Forward, 6 | MoreHorizontal, 7 | Trash2, 8 | type LucideIcon, 9 | } from "lucide-react" 10 | 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuItem, 15 | DropdownMenuSeparator, 16 | DropdownMenuTrigger, 17 | } from "@/components/ui/dropdown-menu" 18 | import { 19 | SidebarGroup, 20 | SidebarGroupLabel, 21 | SidebarMenu, 22 | SidebarMenuAction, 23 | SidebarMenuButton, 24 | SidebarMenuItem, 25 | useSidebar, 26 | } from "@/components/ui/sidebar" 27 | 28 | export function NavProjects({ 29 | projects, 30 | }: { 31 | projects: { 32 | name: string 33 | url: string 34 | icon: LucideIcon 35 | }[] 36 | }) { 37 | const { isMobile } = useSidebar() 38 | 39 | return ( 40 | 41 | Projects 42 | 43 | {projects.map((item) => ( 44 | 45 | 46 | 47 | 48 | {item.name} 49 | 50 | 51 | 52 | 53 | 54 | 55 | More 56 | 57 | 58 | 63 | 64 | 65 | View Project 66 | 67 | 68 | 69 | Share Project 70 | 71 | 72 | 73 | 74 | Delete Project 75 | 76 | 77 | 78 | 79 | ))} 80 | 81 | 82 | 83 | More 84 | 85 | 86 | 87 | 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/components/nav-user.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | BadgeCheck, 5 | Bell, 6 | ChevronsUpDown, 7 | CreditCard, 8 | LogOut, 9 | Sparkles, 10 | } from "lucide-react" 11 | 12 | import { 13 | Avatar, 14 | AvatarFallback, 15 | AvatarImage, 16 | } from "@/components/ui/avatar" 17 | import { 18 | DropdownMenu, 19 | DropdownMenuContent, 20 | DropdownMenuGroup, 21 | DropdownMenuItem, 22 | DropdownMenuLabel, 23 | DropdownMenuSeparator, 24 | DropdownMenuTrigger, 25 | } from "@/components/ui/dropdown-menu" 26 | import { 27 | SidebarMenu, 28 | SidebarMenuButton, 29 | SidebarMenuItem, 30 | useSidebar, 31 | } from "@/components/ui/sidebar" 32 | 33 | export function NavUser({ 34 | user, 35 | }: { 36 | user: { 37 | name: string 38 | email: string 39 | avatar: string 40 | } 41 | }) { 42 | const { isMobile } = useSidebar() 43 | 44 | return ( 45 | 46 | 47 | 48 | 49 | 53 | 54 | 55 | CN 56 | 57 |
58 | {user.name} 59 | {user.email} 60 |
61 | 62 |
63 |
64 | 70 | 71 |
72 | 73 | 74 | CN 75 | 76 |
77 | {user.name} 78 | {user.email} 79 |
80 |
81 |
82 | 83 | 84 | 85 | 86 | Upgrade to Pro 87 | 88 | 89 | 90 | 91 | 92 | 93 | Account 94 | 95 | 96 | 97 | Billing 98 | 99 | 100 | 101 | Notifications 102 | 103 | 104 | 105 | 106 | 107 | Log out 108 | 109 |
110 |
111 |
112 |
113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /src/components/team-switcher.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ChevronsUpDown, Plus } from "lucide-react" 5 | 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuLabel, 11 | DropdownMenuSeparator, 12 | DropdownMenuShortcut, 13 | DropdownMenuTrigger, 14 | } from "@/components/ui/dropdown-menu" 15 | import { 16 | SidebarMenu, 17 | SidebarMenuButton, 18 | SidebarMenuItem, 19 | useSidebar, 20 | } from "@/components/ui/sidebar" 21 | 22 | export function TeamSwitcher({ 23 | teams, 24 | }: { 25 | teams: { 26 | name: string 27 | logo: React.ElementType 28 | plan: string 29 | }[] 30 | }) { 31 | const { isMobile } = useSidebar() 32 | const [activeTeam, setActiveTeam] = React.useState(teams[0]) 33 | 34 | return ( 35 | 36 | 37 | 38 | 39 | 43 |
44 | 45 |
46 |
47 | 48 | {activeTeam.name} 49 | 50 | {activeTeam.plan} 51 |
52 | 53 |
54 |
55 | 61 | 62 | Teams 63 | 64 | {teams.map((team, index) => ( 65 | setActiveTeam(team)} 68 | className="gap-2 p-2" 69 | > 70 |
71 | 72 |
73 | {team.name} 74 | ⌘{index + 1} 75 |
76 | ))} 77 | 78 | 79 |
80 | 81 |
82 |
Add team
83 |
84 |
85 |
86 |
87 |
88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>