From 92be11926b7f2392cef7f06d3b7180943f9c7996 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sun, 14 Sep 2025 19:27:48 +0530 Subject: [PATCH 01/11] author: @nk-ag feat: initialize project structure and add core components - Created initial package.json and package-lock.json files for dependency management. - Added dashboard configuration with components.json for UI structure. - Introduced new UI components including Card, Button, Input, Tabs, Alert, Badge, and others for enhanced user interface. - Implemented global styles in globals.css, including a new color palette and responsive design. - Developed API endpoint for fetching node run details, enhancing data retrieval capabilities. - Updated various components to utilize new UI elements and improve overall functionality. This commit sets the foundation for the Exosphere dashboard, enabling further development and feature integration. --- dashboard/components.json | 22 + dashboard/package-lock.json | 312 +++++++++- dashboard/package.json | 6 + .../src/app/api/node-run-details/route.ts | 42 ++ dashboard/src/app/globals.css | 149 ++++- dashboard/src/app/page.tsx | 180 +++--- .../components/GraphTemplateDetailModal.tsx | 537 +++++++++++------- .../src/components/GraphVisualization.tsx | 537 ++++++++++++------ .../src/components/NamespaceOverview.tsx | 220 +++---- dashboard/src/components/NodeDetailModal.tsx | 225 ++++---- dashboard/src/components/RunsTable.tsx | 330 +++++------ dashboard/src/components/ui/alert.tsx | 66 +++ dashboard/src/components/ui/badge.tsx | 48 ++ dashboard/src/components/ui/button.tsx | 59 ++ dashboard/src/components/ui/card.tsx | 92 +++ dashboard/src/components/ui/input.tsx | 21 + dashboard/src/components/ui/tabs.tsx | 66 +++ dashboard/src/lib/utils.ts | 6 + dashboard/src/services/clientApi.ts | 9 + dashboard/src/types/state-manager.ts | 34 +- package-lock.json | 6 + package.json | 1 + .../app/controller/get_node_run_details.py | 88 +++ .../app/models/node_run_details_models.py | 19 + state-manager/app/routes.py | 24 +- .../controller/test_get_node_run_details.py | 173 ++++++ state-manager/tests/unit/test_routes.py | 41 +- 27 files changed, 2404 insertions(+), 909 deletions(-) create mode 100644 dashboard/components.json create mode 100644 dashboard/src/app/api/node-run-details/route.ts create mode 100644 dashboard/src/components/ui/alert.tsx create mode 100644 dashboard/src/components/ui/badge.tsx create mode 100644 dashboard/src/components/ui/button.tsx create mode 100644 dashboard/src/components/ui/card.tsx create mode 100644 dashboard/src/components/ui/input.tsx create mode 100644 dashboard/src/components/ui/tabs.tsx create mode 100644 dashboard/src/lib/utils.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 state-manager/app/controller/get_node_run_details.py create mode 100644 state-manager/app/models/node_run_details_models.py create mode 100644 state-manager/tests/unit/controller/test_get_node_run_details.py diff --git a/dashboard/components.json b/dashboard/components.json new file mode 100644 index 00000000..71a10880 --- /dev/null +++ b/dashboard/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index abaf39ff..724fd3a6 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -8,14 +8,19 @@ "name": "exosphere-dashboard", "version": "0.1.0", "dependencies": { + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@reactflow/node-resizer": "^2.2.14", "@types/uuid": "^10.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "lucide-react": "^0.544.0", "next": "^15.5.3", "react": "^19.1.0", "react-dom": "^19.1.1", "reactflow": "^11.11.4", "recharts": "^3.2.0", + "tailwind-merge": "^3.3.1", "uuid": "^13.0.0" }, "devDependencies": { @@ -28,6 +33,7 @@ "eslint": "^9", "eslint-config-next": "15.5.3", "tailwindcss": "^4", + "tw-animate-css": "^1.3.8", "typescript": "^5" } }, @@ -970,6 +976,279 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@reactflow/background": { "version": "11.3.14", "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", @@ -1783,7 +2062,7 @@ "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2782,6 +3061,17 @@ "node": ">=18" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/classcat": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", @@ -2798,7 +3088,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", "engines": { "node": ">=6" } @@ -5213,7 +5502,6 @@ "version": "0.544.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", - "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -6520,6 +6808,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", @@ -6658,6 +6955,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tw-animate-css": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.8.tgz", + "integrity": "sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index b9655112..6b494cd4 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -9,14 +9,19 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@reactflow/node-resizer": "^2.2.14", "@types/uuid": "^10.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "lucide-react": "^0.544.0", "next": "^15.5.3", "react": "^19.1.0", "react-dom": "^19.1.1", "reactflow": "^11.11.4", "recharts": "^3.2.0", + "tailwind-merge": "^3.3.1", "uuid": "^13.0.0" }, "devDependencies": { @@ -29,6 +34,7 @@ "eslint": "^9", "eslint-config-next": "15.5.3", "tailwindcss": "^4", + "tw-animate-css": "^1.3.8", "typescript": "^5" } } diff --git a/dashboard/src/app/api/node-run-details/route.ts b/dashboard/src/app/api/node-run-details/route.ts new file mode 100644 index 00000000..58b55b4b --- /dev/null +++ b/dashboard/src/app/api/node-run-details/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE_URL = process.env.EXOSPHERE_STATE_MANAGER_URI || 'http://localhost:8000'; +const API_KEY = process.env.EXOSPHERE_API_KEY; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const namespace = searchParams.get('namespace'); + const graphName = searchParams.get('graphName'); + const runId = searchParams.get('runId'); + const nodeId = searchParams.get('nodeId'); + + if (!namespace || !graphName || !runId || !nodeId) { + return NextResponse.json({ error: 'Namespace, graphName, runId, and nodeId are required' }, { status: 400 }); + } + + if (!API_KEY) { + return NextResponse.json({ error: 'API key not configured' }, { status: 500 }); + } + + const response = await fetch(`${API_BASE_URL}/v0/namespace/${namespace}/graph/${graphName}/run/${runId}/node/${nodeId}`, { + headers: { + 'X-API-Key': API_KEY, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`State manager API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Error fetching node run details:', error); + return NextResponse.json( + { error: 'Failed to fetch node run details' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css index 357474b8..f977a07f 100644 --- a/dashboard/src/app/globals.css +++ b/dashboard/src/app/globals.css @@ -1,14 +1,111 @@ @import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); :root { - --background: #ffffff; - --foreground: #171717; --accent: #031035; --accent-light: #0a1a4a; --accent-lighter: #1a2a5a; --accent-lightest: #2a3a6a; + --radius: 0.625rem; + + /* Updated Color Palette based on #031035 and accents */ + --background: #031035; /* Deep navy primary */ + --foreground: #f8fafc; /* Light text */ + --card: #0a1a4a; /* Slightly lighter navy for cards */ + --card-foreground: #f8fafc; + --popover: #0a1a4a; + --popover-foreground: #f8fafc; + --primary: #87ceeb; /* Sky blue accent */ + --primary-foreground: #031035; + --secondary: #1a2a5a; /* Medium navy */ + --secondary-foreground: #f8fafc; + --muted: #2a3a6a; /* Muted navy */ + --muted-foreground: #94a3b8; /* Light gray text */ + --accent-foreground: #f8fafc; + --destructive: #ff6b8a; /* Pink accent for errors */ + --border: #1a2a5a; /* Navy border */ + --input: #1a2a5a; /* Navy input background */ + --ring: #87ceeb; /* Sky blue focus ring */ + --chart-1: #87ceeb; /* Sky blue */ + --chart-2: #4ade80; /* Green accent */ + --chart-3: #fbbf24; /* Yellow accent */ + --chart-4: #ff6b8a; /* Pink accent */ + --chart-5: #a78bfa; /* Purple accent */ + --sidebar: #0a1a4a; + --sidebar-foreground: #f8fafc; + --sidebar-primary: #87ceeb; + --sidebar-primary-foreground: #031035; + --sidebar-accent: #1a2a5a; + --sidebar-accent-foreground: #f8fafc; + --sidebar-border: #1a2a5a; + --sidebar-ring: #87ceeb; +} + +/* Custom Scrollbar Styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--muted); + border-radius: var(--radius); +} + +::-webkit-scrollbar-thumb { + background: var(--chart-1); + border-radius: var(--radius); + transition: background 0.2s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent-lightest); +} + +::-webkit-scrollbar-corner { + background: var(--muted); +} + +/* Firefox scrollbar styling */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) var(--muted); } +.react-flow__node{ + border-radius: 0.625rem; + border: 1px solid #87ceeb; + background-color: #2a3a6a; + color: #f8fafc; + font-size: 0.875rem; + font-weight: 500; + text-align: center; + transition: all 0.3s ease; + &:hover{ + background-color: #6579b4; + } + &:active{ + background-color: #6579b4; + } + &:focus{ + outline: none; + } + &:disabled{ + background-color: #2a3a6a; + cursor: not-allowed; + } + &:focus-visible{ + outline: none; + } + &:focus-within{ + outline: 2px solid #87ceeb; + } +} + + + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -18,17 +115,45 @@ --color-accent-lightest: var(--accent-lightest); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } -/* @media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground font-sans; } -} */ - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; } diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index 621b7ead..e81244c6 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -19,6 +19,13 @@ import { Filter } from 'lucide-react'; +// Shadcn components +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + export default function Dashboard() { const [activeTab, setActiveTab] = useState< 'overview' | 'graph' |'runs'>('overview'); const [namespace, setNamespace] = useState('default'); @@ -75,6 +82,8 @@ export default function Dashboard() { try { setIsLoading(true); const graphTemplate = await clientApiService.getGraphTemplate(namespace, graphName); + graphTemplate.name = graphName; + graphTemplate.namespace = namespace; setSelectedGraphTemplate(graphTemplate); setIsGraphModalOpen(true); } catch (err) { @@ -89,33 +98,30 @@ export default function Dashboard() { setSelectedGraphTemplate(null); }; - const tabs = [ - { id: 'overview', label: 'Overview', icon: BarChart3 }, - { id: 'graph', label: 'Graph Template', icon: GitBranch }, - { id: 'runs', label: 'Runs', icon: Filter } - ] as const; - return ( -
+
{/* Header */} -
+
-
+
Exosphere Logo -

Exosphere Dashboard

+
+

Exosphere Dashboard

+

AI Workflow State Manager

+
- Namespace: - Namespace: + setNamespace(e.target.value)} - className="px-2 py-1 text-sm text-white border border-gray-300 rounded" + className="w-32 h-8" />
@@ -123,91 +129,91 @@ export default function Dashboard() {
- {/* Navigation Tabs */} - - {/* Main Content */} -
+
{/* Error Display */} {error && ( -
-
- -
-

Error

-
{error}
-
-
-
+ + + {error} + )} {/* Loading Indicator */} {isLoading && ( -
+
-
- Processing... +
+ Processing...
-
+ )} - - {activeTab === 'graph' && ( -
-
-

Graph Template Builder

- {graphTemplate && ( - - )} -
- -
- )} - - {activeTab === 'overview' && ( - - )} - - {activeTab === 'runs' && ( - - )} + {/* Navigation Tabs */} + setActiveTab(value as 'overview' | 'runs')} className="w-full"> + + + + Overview + + {/* + + Graph Template + */} + + + Runs + + + + + + + + + + + + + + +
+ Graph Template Builder + + Design and configure your AI workflow graph templates + +
+ {graphTemplate && ( + + )} +
+ + + +
+
+ + + + + + + + +
{/* Modals */} diff --git a/dashboard/src/components/GraphTemplateDetailModal.tsx b/dashboard/src/components/GraphTemplateDetailModal.tsx index 33b5f01e..dd974374 100644 --- a/dashboard/src/components/GraphTemplateDetailModal.tsx +++ b/dashboard/src/components/GraphTemplateDetailModal.tsx @@ -2,7 +2,13 @@ import React from 'react'; import { UpsertGraphTemplateResponse, NodeTemplate } from '@/types/state-manager'; -import { X, GitBranch, Settings, ArrowRight, Key } from 'lucide-react'; +import { X, GitBranch, Settings, ArrowRight, Key, Code, Database, Workflow, Clock } from 'lucide-react'; + +// Shadcn components +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; interface GraphTemplateDetailModalProps { graphTemplate: UpsertGraphTemplateResponse | null; @@ -10,6 +16,18 @@ interface GraphTemplateDetailModalProps { onClose: () => void; } +const RETRY_STRATEGIES = [ + { value: 'EXPONENTIAL', label: 'Exponential' }, + { value: 'EXPONENTIAL_FULL_JITTER', label: 'Exponential Full Jitter' }, + { value: 'EXPONENTIAL_EQUAL_JITTER', label: 'Exponential Equal Jitter' }, + { value: 'LINEAR', label: 'Linear' }, + { value: 'LINEAR_FULL_JITTER', label: 'Linear Full Jitter' }, + { value: 'LINEAR_EQUAL_JITTER', label: 'Linear Equal Jitter' }, + { value: 'FIXED', label: 'Fixed' }, + { value: 'FIXED_FULL_JITTER', label: 'Fixed Full Jitter' }, + { value: 'FIXED_EQUAL_JITTER', label: 'Fixed Equal Jitter' }, +]; + const GraphVisualizer: React.FC<{ nodes: NodeTemplate[] }> = ({ nodes }) => { const renderNode = (node: NodeTemplate, index: number) => { const connections = node.next_nodes.map(nextNodeId => { @@ -19,26 +37,28 @@ const GraphVisualizer: React.FC<{ nodes: NodeTemplate[] }> = ({ nodes }) => { return (
-
-
-

- {node.identifier} -

- - {index + 1} - -
-
-
Node: {node.node_name}
-
Namespace: {node.namespace}
-
Inputs: {Object.keys(node.inputs).length}
-
-
+ + +
+ {node.identifier} + + {index + 1} + +
+
+ +
+
Node: {node.node_name}
+
Namespace: {node.namespace}
+
Inputs: {Object.keys(node.inputs).length}
+
+
+
{/* Connection lines */} {connections.map((connection, connIndex) => ( -
- +
+
))}
@@ -46,84 +66,209 @@ const GraphVisualizer: React.FC<{ nodes: NodeTemplate[] }> = ({ nodes }) => { }; return ( -
-

- - Graph Structure -

- -
- {nodes.map((node, index) => renderNode(node, index))} -
- - {nodes.length === 0 && ( -
- -

No nodes in this graph template.

+ + + + + Graph Structure + + Visual representation of the workflow nodes + + +
+ {nodes.map((node, index) => renderNode(node, index))}
- )} -
+ + {nodes.length === 0 && ( +
+ +

No nodes in this graph template.

+
+ )} + + ); }; const NodeDetailView: React.FC<{ node: NodeTemplate; index: number }> = ({ node, index }) => { return ( -
-
-

- Node {index + 1}: {node.identifier} -

- - {index + 1} - -
+ + +
+ + Node {index + 1}: {node.identifier} + + + {index + 1} + +
+
+ +
+
+ +
+ {node.node_name} +
+
-
-
- -
- {node.node_name} +
+ +
+ {node.namespace} +
-
-
- -
- {node.namespace} +
+ +
+ {node.identifier} +
+
+ +
+ +
+ {node.next_nodes.length > 0 ? node.next_nodes.join(', ') : 'None'} +
- -
- {node.identifier} + +
+
+              {JSON.stringify(node.inputs, null, 2)}
+            
+ + + ); +}; -
- -
- {node.next_nodes.length > 0 ? node.next_nodes.join(', ') : 'None'} +const RetryPolicyViewer: React.FC<{ + retryPolicy: any; +}> = ({ retryPolicy }) => { + const getStrategyLabel = (strategy: string) => { + return RETRY_STRATEGIES.find(s => s.value === strategy)?.label || strategy; + }; + + return ( + + + + + Retry Policy Configuration + + Current retry policy settings for handling node execution failures + + +
+
+ +
+ {retryPolicy.max_retries} +
+

Maximum number of retry attempts

+
+ +
+ +
+ {getStrategyLabel(retryPolicy.strategy)} +
+

Strategy for calculating retry delays

+
+ +
+ +
+ {retryPolicy.backoff_factor} ms +
+

Base delay in milliseconds

+
+ +
+ +
+ {retryPolicy.exponent} +
+

Multiplier for exponential strategies

+
+ +
+ +
+ {retryPolicy.max_delay ? `${retryPolicy.max_delay} ms` : 'No maximum delay'} +
+

Maximum delay cap in milliseconds

-
+ + + ); +}; -
- -
-          {JSON.stringify(node.inputs, null, 2)}
-        
-
+const StoreConfigViewer: React.FC<{ + storeConfig: any; +}> = ({ storeConfig }) => { + return ( +
+ + + + + Required Keys + + Keys that must be present in the store when triggering the graph + + +
+ {storeConfig.required_keys && storeConfig.required_keys.length > 0 ? ( + storeConfig.required_keys.map((key: string, index: number) => ( +
+ {key} +
+ )) + ) : ( +
+

No required keys configured

+
+ )} +
+
+
+ + + + + + Default Values + + Default values for store keys when they are not provided + + +
+ {storeConfig.default_values && Object.keys(storeConfig.default_values).length > 0 ? ( + Object.entries(storeConfig.default_values).map(([key, value]) => ( +
+
+ {key} +
+
+ {value as string} +
+
+ )) + ) : ( +
+

No default values configured

+
+ )} +
+
+
); }; @@ -136,141 +281,139 @@ export const GraphTemplateDetailModal: React.FC = if (!isOpen || !graphTemplate) return null; return ( -
-
+
+ {/* Header */} -
+
-

Graph Template

-

- Created: {new Date(graphTemplate.created_at).toLocaleString()} -

-

- Updated: {new Date(graphTemplate.updated_at).toLocaleString()} -

+ {graphTemplate.name} + + Graph Template Configuration +
- + +
-
+ {/* Content */} -
- {/* Validation Status */} -
-
-
-
-

- Validation Status: {graphTemplate.validation_status} -

- {graphTemplate.validation_errors && graphTemplate.validation_errors.length > 0 && ( -
-

Validation Errors:

-
    - {graphTemplate.validation_errors.map((error, index) => ( -
  • • {error}
  • - ))} -
-
- )} -
-
-
+ + + + Overview + Visualization + Nodes + Retry Policy + Store Config + - {/* Graph Visualization */} -
-

Graph Structure

- -
+ +
+ + + + + Template Information + + + +
+ +
{graphTemplate.name}
+
+
+ +
{graphTemplate.namespace}
+
+
+ +
+ {new Date(graphTemplate.created_at).toLocaleString()} +
+
+
+
- {/* Secrets */} - {Object.keys(graphTemplate.secrets).length > 0 && ( -
-

- - Secrets Configuration -

-
- {Object.entries(graphTemplate.secrets).map(([key, isConfigured]) => ( -
- {key} - - {isConfigured ? 'Configured' : 'Missing'} - -
- ))} + + + + + Statistics + + + +
+ Total Nodes + {graphTemplate.nodes?.length || 0} +
+
+ Secrets + + {graphTemplate.secrets ? Object.keys(graphTemplate.secrets).length : 0} + +
+
+ Status + + {graphTemplate.validation_status} + +
+
+
-
- )} - {/* Node Details */} -
-

Node Details

-
- {graphTemplate.nodes.map((node, index) => ( - - ))} -
-
+ {graphTemplate.validation_errors && ( + + + Validation Errors + + +
+ {graphTemplate.validation_errors} +
+
+
+ )} + - {/* Summary */} -
-

Graph Summary

-
-
- Nodes: - {graphTemplate.nodes.length} -
-
- Secrets: - {Object.keys(graphTemplate.secrets).length} -
-
- Status: - {graphTemplate.validation_status} -
-
- Connections: - - {graphTemplate.nodes.reduce((total, node) => total + node.next_nodes.length, 0)} - -
-
-
-
+ + + - {/* Footer */} -
- -
-
+ + {graphTemplate.nodes && graphTemplate.nodes.length > 0 ? ( +
+ {graphTemplate.nodes.map((node, index) => ( + + ))} +
+ ) : ( + + + +

No Nodes

+

This graph template doesn't have any nodes configured.

+
+
+ )} +
+ + + + + + + + + + +
); }; diff --git a/dashboard/src/components/GraphVisualization.tsx b/dashboard/src/components/GraphVisualization.tsx index 7f763884..d790d4ed 100644 --- a/dashboard/src/components/GraphVisualization.tsx +++ b/dashboard/src/components/GraphVisualization.tsx @@ -18,7 +18,8 @@ import 'reactflow/dist/style.css'; import { clientApiService } from '@/services/clientApi'; import { GraphStructureResponse, - GraphNode as GraphNodeType + GraphNode as GraphNodeType, + NodeRunDetailsResponse } from '@/types/state-manager'; import { RefreshCw, @@ -30,6 +31,9 @@ import { Network, BarChart3 } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; interface GraphVisualizationProps { namespace: string; @@ -45,68 +49,66 @@ const CustomNode: React.FC<{ node: GraphNodeType; }; }> = ({ data }) => { - const getStatusColor = (status: string) => { + const getStatusVariant = (status: string): "default" | "success" | "destructive" | "secondary" => { switch (status) { - case 'CREATED': - return 'bg-[#031035]'; - case 'QUEUED': - return 'bg-[#0a1a4a]'; case 'EXECUTED': case 'SUCCESS': - return 'bg-green-400'; + return 'success'; case 'ERRORED': case 'TIMEDOUT': case 'CANCELLED': - return 'bg-red-500'; + return 'destructive'; + case 'QUEUED': + return 'secondary'; default: - return 'bg-gray-500'; + return 'default'; } }; const getStatusIcon = (status: string) => { switch (status) { case 'CREATED': - return ; + return ; case 'QUEUED': - return ; + return ; case 'EXECUTED': case 'SUCCESS': - return ; + return ; case 'ERRORED': case 'TIMEDOUT': case 'CANCELLED': - return ; + return ; default: - return ; + return ; } }; return ( -
+
{/* Source Handle (Right side) */} {/* Target Handle (Left side) */} -
+
{getStatusIcon(data.status)} - + {data.status} - +
-
{data.label}
-
{data.identifier}
+
{data.label}
+
{data.identifier}
); }; @@ -123,6 +125,9 @@ export const GraphVisualization: React.FC = ({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [selectedNode, setSelectedNode] = useState(null); + const [selectedNodeDetails, setSelectedNodeDetails] = useState(null); + const [isLoadingNodeDetails, setIsLoadingNodeDetails] = useState(false); + const [nodeDetailsError, setNodeDetailsError] = useState(null); const loadGraphStructure = async () => { setIsLoading(true); @@ -138,6 +143,20 @@ export const GraphVisualization: React.FC = ({ } }; + const loadNodeDetails = async (nodeId: string, graphName: string) => { + setIsLoadingNodeDetails(true); + setNodeDetailsError(null); + + try { + const details = await clientApiService.getNodeRunDetails(namespace, graphName, runId, nodeId); + setSelectedNodeDetails(details); + } catch (err) { + setNodeDetailsError(err instanceof Error ? err.message : 'Failed to load node details'); + } finally { + setIsLoadingNodeDetails(false); + } + }; + useEffect(() => { if (namespace && runId) { loadGraphStructure(); @@ -258,13 +277,13 @@ export const GraphVisualization: React.FC = ({ animated: false, markerEnd: { type: MarkerType.ArrowClosed, - width: 20, - height: 20, - color: '#10b981', + width: 10, + height: 10, + color: '#87ceeb', }, style: { - stroke: '#10b981', - strokeWidth: 3, + stroke: '#87ceeb', + strokeWidth: 2, strokeDasharray: 'none', }, })); @@ -282,206 +301,346 @@ export const GraphVisualization: React.FC = ({ }, [nodes, edges, setReactFlowNodes, setReactFlowEdges]); const onNodeClick = useCallback((event: React.MouseEvent, node: Node) => { - setSelectedNode(node.data.node); - }, []); + const graphNode = node.data.node; + setSelectedNode(graphNode); + setSelectedNodeDetails(null); // Clear previous details + + // Load detailed node information + if (graphData?.graph_name) { + loadNodeDetails(graphNode.id, graphData.graph_name); + } + }, [graphData?.graph_name]); if (isLoading) { return (
- - Loading graph structure... + + Loading graph structure...
); } if (error) { return ( -
-
- -
-

Error

-
{error}
- + + +
+ +
+

Error

+
{error}
+ +
-
-
+ + ); } if (!graphData) { return ( -
- -

No graph data available

-
+ + +
+ +

No graph data available

+
+
+
); } return ( -
-
-
- -
-

Graph Visualization

-

- Run ID: {runId} | Graph: {graphData.graph_name} -

-
-
- -
- - {/* Execution Summary */} -
-
- -

Execution Summary

-
-
- {Object.entries(graphData.execution_summary).map(([status, count]) => ( -
-
{count}
-
{status.toLowerCase()}
-
- ))} +
+ {/* Header */} +
+
+ +
+

Graph Visualization

+

+ Run ID: {runId} | Graph: {graphData.graph_name} +

+
+
- {/* Graph Visualization */} -
-
-

Graph Structure

-
- {graphData.node_count} nodes, {graphData.edge_count} edges | - React Flow: {reactFlowNodes.length} nodes, {reactFlowEdges.length} edges -
-
- -
- - - - -
-
+ {/* Execution Summary */} + + +
+ + Execution Summary +
+
+ +
+ {Object.entries(graphData.execution_summary).map(([status, count]) => ( +
+
{count}
+
{status.toLowerCase()}
+
+ ))} +
+
+
+ + {/* Graph Visualization */} + + +
+ Graph Structure + + {graphData.node_count} nodes, {graphData.edge_count} edges + +
+
+ +
+ + + +
+
+
{/* Node Details Modal */} {selectedNode && ( -
-
-
-

Node Details

- -
+
+ + +
+ Node Details + +
+
-
-
- {(() => { - switch (selectedNode.status) { - case 'CREATED': - return ; - case 'QUEUED': - return ; - case 'EXECUTED': - case 'SUCCESS': - return ; - case 'ERRORED': - case 'TIMEDOUT': - case 'CANCELLED': - return ; - default: - return ; - } - })()} -
-

{selectedNode.node_name}

-

{selectedNode.identifier}

+ + {/* Loading State */} + {isLoadingNodeDetails && ( +
+ + Loading node details...
- { - switch (selectedNode.status) { - case 'CREATED': return 'bg-[#031035]'; - case 'QUEUED': return 'bg-[#0a1a4a]'; - case 'EXECUTED': - case 'SUCCESS': return 'bg-green-400'; - case 'ERRORED': - case 'TIMEDOUT': - case 'CANCELLED': return 'bg-red-500'; - default: return 'bg-gray-500'; - } - })() - } text-white`}> - {selectedNode.status} - -
+ )} -
-
-
Node Information
-
-

ID: {selectedNode.id}

-

Name: {selectedNode.node_name}

-

Identifier: {selectedNode.identifier}

+ {/* Error State */} + {nodeDetailsError && ( +
+ +
+

{nodeDetailsError}

-
-
Status
-
-

Current Status: {selectedNode.status}

-
+ )} + + {/* Node Header - always show basic info */} +
+ {(() => { + const status = selectedNodeDetails?.status || selectedNode.status; + switch (status) { + case 'CREATED': + return ; + case 'QUEUED': + return ; + case 'EXECUTED': + case 'SUCCESS': + return ; + case 'ERRORED': + case 'TIMEDOUT': + case 'CANCELLED': + return ; + default: + return ; + } + })()} +
+

{selectedNodeDetails?.node_name || selectedNode.node_name}

+

{selectedNodeDetails?.identifier || selectedNode.identifier}

+ { + const status = selectedNodeDetails?.status || selectedNode.status; + switch (status) { + case 'EXECUTED': + case 'SUCCESS': + return 'success' as const; + case 'ERRORED': + case 'TIMEDOUT': + case 'CANCELLED': + return 'destructive' as const; + case 'QUEUED': + return 'secondary' as const; + default: + return 'default' as const; + } + })()}> + {selectedNodeDetails?.status || selectedNode.status} +
- {selectedNode.error && ( -
-
Error
-
- {selectedNode.error} + {/* Only show detailed sections if not loading and no error */} + {!isLoadingNodeDetails && !nodeDetailsError && ( + <> +
+
+
Node Information
+
+
+ ID: + {selectedNodeDetails?.id || selectedNode.id} +
+
+ Name: + {selectedNodeDetails?.node_name || selectedNode.node_name} +
+
+ Identifier: + {selectedNodeDetails?.identifier || selectedNode.identifier} +
+ {selectedNodeDetails?.graph_name && ( +
+ Graph: + {selectedNodeDetails.graph_name} +
+ )} + {selectedNodeDetails?.run_id && ( +
+ Run ID: + {selectedNodeDetails.run_id} +
+ )} +
+
+
+
Status & Timestamps
+
+
+ Current Status: + {selectedNodeDetails?.status || selectedNode.status} +
+ {selectedNodeDetails?.created_at && ( +
+ Created: + {new Date(selectedNodeDetails.created_at).toLocaleString()} +
+ )} + {selectedNodeDetails?.updated_at && ( +
+ Updated: + {new Date(selectedNodeDetails.updated_at).toLocaleString()} +
+ )} +
+
-
+ + {/* Error Section */} + {(selectedNodeDetails?.error || selectedNode.error) && ( +
+
Error
+
+ {selectedNodeDetails?.error || selectedNode.error} +
+
+ )} + + {/* Parent Nodes Section */} + {selectedNodeDetails?.parents && Object.keys(selectedNodeDetails.parents).length > 0 && ( +
+
Parent Nodes
+
+
+ {Object.entries(selectedNodeDetails.parents).map(([identifier, parentId]) => ( +
+ {identifier}: + {parentId} +
+ ))} +
+
+
+ )} + + {/* Inputs Section */} +
+
Inputs
+
+ {(() => { + const inputs = selectedNodeDetails?.inputs || selectedNode.inputs || {}; + return Object.keys(inputs).length > 0 ? ( +
+                            {JSON.stringify(inputs, null, 2)}
+                          
+ ) : ( +

No inputs

+ ); + })()} +
+
+ + {/* Outputs Section */} +
+
Outputs
+
+ {(() => { + const outputs = selectedNodeDetails?.outputs || selectedNode.outputs || {}; + return Object.keys(outputs).length > 0 ? ( +
+                            {JSON.stringify(outputs, null, 2)}
+                          
+ ) : ( +

No outputs

+ ); + })()} +
+
+ )} -
- Node ID: {selectedNode.id} +
+ Node ID: {selectedNodeDetails?.id || selectedNode.id}
-
-
+ +
)}
diff --git a/dashboard/src/components/NamespaceOverview.tsx b/dashboard/src/components/NamespaceOverview.tsx index 20c27293..f1e69d6f 100644 --- a/dashboard/src/components/NamespaceOverview.tsx +++ b/dashboard/src/components/NamespaceOverview.tsx @@ -18,6 +18,12 @@ import { Loader2 } from 'lucide-react'; +// Shadcn components +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; + interface NamespaceOverviewProps { namespace: string; onOpenNode?: (node: NodeRegistration) => void; @@ -59,173 +65,149 @@ export const NamespaceOverview: React.FC = ({ const getValidationStatusColor = (status: string) => { switch (status) { case 'VALID': - return 'text-green-600 bg-green-100'; + return 'success'; + case 'INVALID': + return 'destructive'; + case 'PENDING': + return 'secondary'; + default: + return 'outline'; + } + }; + + const getValidationIcon = (status: string) => { + switch (status) { + case 'VALID': + return ; case 'INVALID': - return 'text-red-600 bg-red-100'; + return ; case 'PENDING': - return 'text-yellow-600 bg-yellow-100'; + return ; default: - return 'text-gray-600 bg-gray-100'; + return ; } }; if (isLoading) { return (
- - Loading namespace data... + + Loading namespace data...
); } if (error) { return ( -
-
- -
-

Error

-
{error}
- -
-
-
+ + + + Error loading namespace data: {error} + + ); } return ( -
-
-

Namespace Overview

- +
{/* Registered Nodes */} -
-
+ +
- -

Registered Nodes

+ + Registered Nodes
- + {nodesResponse?.count || 0} nodes
-
+ + Active workflow nodes in the {namespace} namespace + + -
+ {nodesResponse?.nodes && nodesResponse.nodes.length > 0 ? ( -
+
{nodesResponse.nodes.map((node, index) => (
onOpenNode?.(node)} >
-

{node.name}

- - {node.secrets.length} secrets - -
-
-
-
- Inputs: {Object.keys(node.inputs_schema.properties || {}).length} -
-
- Outputs: {Object.keys(node.outputs_schema.properties || {}).length} -
-
+

{node.name}

+
))}
) : ( -
- +
+

No registered nodes found

+

Nodes will appear here once registered

)} -
-
+
+ {/* Graph Templates */} -
-
+ +
- -

Graph Templates

+ + Graph Templates
- + {templatesResponse?.count || 0} templates
-
+ + Workflow graph templates in the {namespace} namespace + + -
+ {templatesResponse?.templates && templatesResponse.templates.length > 0 ? ( -
+
{templatesResponse.templates.map((template, index) => ( -
+
onOpenGraphTemplate?.(template.name)} + >
-

- {template.name} -

- +

{template.name}

+ + {getValidationIcon(template.validation_status)} {template.validation_status} -
+
-
-
-
- Nodes: {template.nodes.length} -
-
- Secrets: {Object.keys(template.secrets ?? {}).length} -
-
-
- Created: {new Date(template.created_at).toLocaleDateString()} -
- {template.validation_errors && template.validation_errors.length > 0 && ( -
- Errors: -
    - {template.validation_errors.map((error, i) => ( -
  • • {error}
  • - ))} -
-
- )} - {onOpenGraphTemplate && ( -
- +
+
Namespace: {template.namespace}
+
Nodes: {template.nodes?.length || 0}
+ {template.validation_errors && ( +
+ {template.validation_errors}
)}
@@ -233,34 +215,14 @@ export const NamespaceOverview: React.FC = ({ ))}
) : ( -
- +
+

No graph templates found

+

Templates will appear here once created

)} -
-
-
- - {/* Summary Stats */} -
-

Namespace Summary

-
-
-
{nodesResponse?.count || 0}
-
Registered Nodes
-
-
-
{templatesResponse?.count || 0}
-
Graph Templates
-
-
-
- {templatesResponse?.templates?.filter(t => t.validation_status === 'VALID').length || 0} -
-
Valid Templates
-
-
+ +
); diff --git a/dashboard/src/components/NodeDetailModal.tsx b/dashboard/src/components/NodeDetailModal.tsx index 4118cfa0..38e37603 100644 --- a/dashboard/src/components/NodeDetailModal.tsx +++ b/dashboard/src/components/NodeDetailModal.tsx @@ -2,7 +2,13 @@ import React from 'react'; import { NodeRegistration } from '@/types/state-manager'; -import { X, Code, Eye, EyeOff, Key } from 'lucide-react'; +import { X, Code, Eye, EyeOff, Key, ChevronDown, ChevronRight } from 'lucide-react'; + +// Shadcn components +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; interface SchemaProperty { type: string; @@ -27,71 +33,75 @@ const SchemaRenderer: React.FC<{ schema: Schema; title: string }> = ({ schema, t const renderSchemaProperties = (properties: Record, required: string[] = []) => { return Object.entries(properties).map(([key, value]: [string, SchemaProperty]) => ( -
+
- + {key} {required.includes(key) && ( - + required - + )}
- + {value.type} - +
{value.description && ( -

{value.description}

+

{value.description}

)} {value.enum && (
- Values: - - {value.enum.join(', ')} - + Options: +
+ {value.enum.map((option, idx) => ( + + {option} + + ))} +
)}
)); }; + if (!schema || !schema.properties) { + return ( + + + {title} + + +

No schema defined

+
+
+ ); + } + return ( -
-
setIsExpanded(!isExpanded)} - > -

- - {title} -

- {isExpanded ? ( - - ) : ( - - )} -
- + + + + {isExpanded && ( -
- {schema.properties && ( -
-
Properties:
- {renderSchemaProperties(schema.properties, schema.required || [])} -
- )} - - {schema.type && ( -
- Type: {schema.type} -
- )} -
+ + {renderSchemaProperties(schema.properties, schema.required)} + )} -
+ ); }; @@ -100,103 +110,70 @@ export const NodeDetailModal: React.FC = ({ isOpen, onClose }) => { + const [showSecrets, setShowSecrets] = React.useState(false); + if (!isOpen || !node) return null; return ( -
-
+
+ {/* Header */} -
-
+ +
-

{node.name}

-

+ {node.name} + Node Schema Details -

+
- + +
-
+ {/* Content */} -
- {/* Secrets Section */} - {node.secrets.length > 0 && ( -
-

- - Required Secrets -

-
- {node.secrets.map((secret, index) => ( - - {secret} - - ))} -
-
- )} - - {/* Input Schema */} -
-

Input Schema

- -
+ + + + Overview + Inputs + Outputs - {/* Output Schema */} -
-

Output Schema

- -
+
+ + +
+ + + Node Information + + +
+ +
{node.name}
+
+
+
- {/* Summary */} -
-

Node Summary

-
-
- Secrets: - {node.secrets.length} -
-
- Inputs: - - {Object.keys(node.inputs_schema.properties || {}).length} - -
-
- Outputs: - - {Object.keys(node.outputs_schema.properties || {}).length} -
-
-
-
+
- {/* Footer */} -
- -
-
+ + + + + + + + + + +
); }; diff --git a/dashboard/src/components/RunsTable.tsx b/dashboard/src/components/RunsTable.tsx index 3add88e2..9bd1dc0f 100644 --- a/dashboard/src/components/RunsTable.tsx +++ b/dashboard/src/components/RunsTable.tsx @@ -19,6 +19,12 @@ import { Hash } from 'lucide-react'; +// Shadcn components +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + interface RunsTableProps { namespace: string; } @@ -102,7 +108,7 @@ export const RunsTable: React.FC = ({ setShowGraph(false); }; - const handleVisualizeGraph = (runId: string) => { + const handleRowClick = (runId: string) => { setSelectedRunId(runId); setShowGraph(true); }; @@ -123,13 +129,13 @@ export const RunsTable: React.FC = ({ const getStatusColor = (status: RunStatusEnum) => { switch (status) { case RunStatusEnum.SUCCESS: - return 'bg-green-100 text-green-800 border-green-200'; + return 'success'; case RunStatusEnum.PENDING: - return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + return 'secondary'; case RunStatusEnum.FAILED: - return 'bg-red-100 text-red-800 border-red-200'; + return 'destructive'; default: - return 'bg-gray-100 text-gray-800 border-gray-200'; + return 'outline'; } }; @@ -141,190 +147,192 @@ export const RunsTable: React.FC = ({ if (isLoading && !runsData) { return (
- - Loading runs... + + Loading runs...
); } if (error) { return ( -
-
- -
-

Error

-
{error}
- -
-
-
+ + + + Error loading runs: {error} + + + ); } return ( -
+
{/* Header */} -
+
- +
-

Runs

-

Monitor and visualize workflow executions

+

Workflow Runs

+

Monitor and visualize workflow executions

-
- - setRefreshInterval(Number(e.target.value) as RefreshMs)} + className="px-3 py-2 text-sm border border-input rounded-md bg-background shadow-sm + focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary + hover:border-muted-foreground transition-colors" > {REFRESH_OPTIONS.map((option) => ( ))} - + +
- +
{/* Graph Visualization */} {showGraph && selectedRunId && ( -
-
-
-

- Graph Visualization for Run: {selectedRunId} -

- -
+ + + Graph Visualization for Run: {selectedRunId} + + + -
-
+ + )} {/* Runs Table */} -
-
-
-

- {runsData ? `${runsData.total} total runs` : 'Loading runs...'} -

-
- - -
+ + + + {runsData ? `${runsData.total} total runs` : 'Loading runs...'} + +
+ +
-
+ + -
- - - - - - - - - - - - - +
+
- Run ID - - Graph Name - - Status - - Progress - - States - - Date & Time - - Actions -
+ + + + + + + + + + + {runsData?.runs.map((run) => ( - + handleRowClick(run.run_id)} + > - ))}
+ Run ID + + Graph Name + + Status + + Progress + + States + + Date & Time +
- - + + {run.run_id.slice(0, 8)}...
- + {run.graph_name}
{getStatusIcon(run.status)} - + {run.status} - +
-
+
- + {getProgressPercentage(run)}%
-
+
@@ -338,72 +346,66 @@ export const RunsTable: React.FC = ({ {run.errored_count} - / {run.total_count} + / {run.total_count}
- - + + {new Date(run.created_at).toLocaleDateString()} {new Date(run.created_at).toLocaleTimeString()}
- -
- {/* Pagination */} - {runsData && runsData.total > pageSize && ( -
-
-
- Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, runsData.total)} of {runsData.total} results -
-
- - - Page {currentPage} of {Math.ceil(runsData.total / pageSize)} - - + {/* Pagination */} + {runsData && runsData.total > pageSize && ( +
+
+
+ Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, runsData.total)} of {runsData.total} results +
+
+ + + Page {currentPage} of {Math.ceil(runsData.total / pageSize)} + + +
-
- )} -
+ )} + + {/* Empty State */} {runsData && runsData.runs.length === 0 && (
- -

No runs found

-

There are no runs in this namespace yet.

+ +

No runs found

+

There are no runs in this namespace yet.

)}
diff --git a/dashboard/src/components/ui/alert.tsx b/dashboard/src/components/ui/alert.tsx new file mode 100644 index 00000000..14213546 --- /dev/null +++ b/dashboard/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/dashboard/src/components/ui/badge.tsx b/dashboard/src/components/ui/badge.tsx new file mode 100644 index 00000000..b9096be5 --- /dev/null +++ b/dashboard/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + success: + "border-transparent bg-chart-2 text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/dashboard/src/components/ui/button.tsx b/dashboard/src/components/ui/button.tsx new file mode 100644 index 00000000..a2df8dce --- /dev/null +++ b/dashboard/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/dashboard/src/components/ui/card.tsx b/dashboard/src/components/ui/card.tsx new file mode 100644 index 00000000..d05bbc6c --- /dev/null +++ b/dashboard/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/dashboard/src/components/ui/input.tsx b/dashboard/src/components/ui/input.tsx new file mode 100644 index 00000000..03295ca6 --- /dev/null +++ b/dashboard/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/dashboard/src/components/ui/tabs.tsx b/dashboard/src/components/ui/tabs.tsx new file mode 100644 index 00000000..497ba5ea --- /dev/null +++ b/dashboard/src/components/ui/tabs.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/dashboard/src/lib/utils.ts b/dashboard/src/lib/utils.ts new file mode 100644 index 00000000..bd0c391d --- /dev/null +++ b/dashboard/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/dashboard/src/services/clientApi.ts b/dashboard/src/services/clientApi.ts index fea7e272..47fa19c4 100644 --- a/dashboard/src/services/clientApi.ts +++ b/dashboard/src/services/clientApi.ts @@ -21,6 +21,15 @@ export class ClientApiService { return response.json(); } + // Node Run Details + async getNodeRunDetails(namespace: string, graphName: string, runId: string, nodeId: string) { + const response = await fetch(`/api/node-run-details?namespace=${encodeURIComponent(namespace)}&graphName=${encodeURIComponent(graphName)}&runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(nodeId)}`); + if (!response.ok) { + throw new Error(`Failed to fetch node run details: ${response.statusText}`); + } + return response.json(); + } + // Namespace Overview async getNamespaceOverview(namespace: string) { const response = await fetch(`/api/namespace-overview?namespace=${encodeURIComponent(namespace)}`); diff --git a/dashboard/src/types/state-manager.ts b/dashboard/src/types/state-manager.ts index 663e7465..d44c256c 100644 --- a/dashboard/src/types/state-manager.ts +++ b/dashboard/src/types/state-manager.ts @@ -5,6 +5,19 @@ export interface NodeRegistration { secrets: string[]; } +export interface RetryPolicyModel { + max_retries: number; + strategy: string; + backoff_factor: number; + exponent: number; + max_delay: number; +} + +export interface StoreConfig { + required_keys: string[]; + default_values: Record; +} + export interface RegisterNodesRequest { runtime_name: string; nodes: NodeRegistration[]; @@ -30,9 +43,11 @@ export interface UpsertGraphTemplateRequest { export interface UpsertGraphTemplateResponse { name: string; - namespace: string; + namespace: string; nodes: NodeTemplate[]; secrets: Record; + retry_policy: RetryPolicyModel; + store_config: StoreConfig; created_at: string; updated_at: string; validation_status: GraphTemplateValidationStatus; @@ -164,6 +179,8 @@ export interface GraphNode { node_name: string; identifier: string; status: StateStatus; + inputs: Record; + outputs: Record; error?: string; } @@ -182,6 +199,21 @@ export interface GraphStructureResponse { execution_summary: Record; } +export interface NodeRunDetailsResponse { + id: string; + node_name: string; + identifier: string; + graph_name: string; + run_id: string; + status: StateStatus; + inputs: Record; + outputs: Record; + error?: string; + parents: Record; + created_at: string; + updated_at: string; +} + // Runs Types export enum RunStatusEnum { SUCCESS = "SUCCESS", diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..a02149d0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "exospherehost", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} diff --git a/state-manager/app/controller/get_node_run_details.py b/state-manager/app/controller/get_node_run_details.py new file mode 100644 index 00000000..2c76ae87 --- /dev/null +++ b/state-manager/app/controller/get_node_run_details.py @@ -0,0 +1,88 @@ +""" +Controller for getting detailed information about a specific node in a run +""" +from fastapi import HTTPException, status +from beanie import PydanticObjectId + +from ..models.db.state import State +from ..models.node_run_details_models import NodeRunDetailsResponse +from ..singletons.logs_manager import LogsManager + + +async def get_node_run_details(namespace: str, graph_name: str, run_id: str, node_id: str, request_id: str) -> NodeRunDetailsResponse: + """ + Get detailed information about a specific node in a run + + Args: + namespace: The namespace to search in + graph_name: The graph name to filter by + run_id: The run ID to filter by + node_id: The node ID (state ID) to get details for + request_id: Request ID for logging + + Returns: + NodeRunDetailsResponse containing detailed node information + """ + logger = LogsManager().get_logger() + + try: + logger.info(f"Getting node run details for node ID: {node_id} in run: {run_id}, graph: {graph_name}, namespace: {namespace}", x_exosphere_request_id=request_id) + + # Convert node_id to ObjectId if it's a valid ObjectId string + try: + node_object_id = PydanticObjectId(node_id) + except Exception: + logger.error(f"Invalid node ID format: {node_id}", x_exosphere_request_id=request_id) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid node ID format: {node_id}" + ) + + # Find the specific state + state = await State.find_one( + State.id == node_object_id, + State.run_id == run_id, + State.graph_name == graph_name, + State.namespace_name == namespace + ) + + if not state: + logger.warning(f"Node not found: {node_id} in run: {run_id}, graph: {graph_name}, namespace: {namespace}", x_exosphere_request_id=request_id) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Node {node_id} not found in run {run_id} for graph {graph_name}" + ) + + # Convert parent ObjectIds to strings + parent_identifiers = {} + for identifier, parent_id in state.parents.items(): + parent_identifiers[identifier] = str(parent_id) + + # Create response + response = NodeRunDetailsResponse( + id=str(state.id), + node_name=state.node_name, + identifier=state.identifier, + graph_name=state.graph_name, + run_id=state.run_id, + status=state.status, + inputs=state.inputs, + outputs=state.outputs, + error=state.error, + parents=parent_identifiers, + created_at=state.created_at.isoformat() if state.created_at else "", + updated_at=state.updated_at.isoformat() if state.updated_at else "" + ) + + logger.info(f"Successfully retrieved node run details for node ID: {node_id}", x_exosphere_request_id=request_id) + return response + + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + logger.error(f"Error getting node run details for node ID: {node_id}: {str(e)}", x_exosphere_request_id=request_id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error while retrieving node details" + ) \ No newline at end of file diff --git a/state-manager/app/models/node_run_details_models.py b/state-manager/app/models/node_run_details_models.py new file mode 100644 index 00000000..a5997cb6 --- /dev/null +++ b/state-manager/app/models/node_run_details_models.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Field +from typing import Dict, Any, Optional +from .state_status_enum import StateStatusEnum + + +class NodeRunDetailsResponse(BaseModel): + """Response model for node run details API""" + id: str = Field(..., description="Unique identifier for the node (state ID)") + node_name: str = Field(..., description="Name of the node") + identifier: str = Field(..., description="Identifier of the node") + graph_name: str = Field(..., description="Name of the graph template") + run_id: str = Field(..., description="Run ID of the execution") + status: StateStatusEnum = Field(..., description="Status of the state") + inputs: Dict[str, Any] = Field(..., description="Inputs of the state") + outputs: Dict[str, Any] = Field(..., description="Outputs of the state") + error: Optional[str] = Field(None, description="Error message if any") + parents: Dict[str, str] = Field(..., description="Parent node identifiers") + created_at: str = Field(..., description="Creation timestamp") + updated_at: str = Field(..., description="Last update timestamp") \ No newline at end of file diff --git a/state-manager/app/routes.py b/state-manager/app/routes.py index d71d3e41..09f49053 100644 --- a/state-manager/app/routes.py +++ b/state-manager/app/routes.py @@ -39,6 +39,9 @@ from .models.graph_structure_models import GraphStructureResponse from .controller.get_graph_structure import get_graph_structure +from .models.node_run_details_models import NodeRunDetailsResponse +from .controller.get_node_run_details import get_node_run_details + ### signals from .models.signal_models import SignalResponseModel from .models.signal_models import PruneRequestModel @@ -329,4 +332,23 @@ async def get_graph_structure_route(namespace_name: str, run_id: str, request: R logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") - return await get_graph_structure(namespace_name, run_id, x_exosphere_request_id) \ No newline at end of file + return await get_graph_structure(namespace_name, run_id, x_exosphere_request_id) + + +@router.get( + "/graph/{graph_name}/run/{run_id}/node/{node_id}", + response_model=NodeRunDetailsResponse, + status_code=status.HTTP_200_OK, + response_description="Node run details retrieved successfully", + tags=["runs"] +) +async def get_node_run_details_route(namespace_name: str, graph_name: str, run_id: str, node_id: str, request: Request, api_key: str = Depends(check_api_key)): + x_exosphere_request_id = getattr(request.state, "x_exosphere_request_id", str(uuid4())) + + if api_key: + logger.info(f"API key is valid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + else: + logger.error(f"API key is invalid for namespace {namespace_name}", x_exosphere_request_id=x_exosphere_request_id) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + return await get_node_run_details(namespace_name, graph_name, run_id, node_id, x_exosphere_request_id) \ No newline at end of file diff --git a/state-manager/tests/unit/controller/test_get_node_run_details.py b/state-manager/tests/unit/controller/test_get_node_run_details.py new file mode 100644 index 00000000..e8ca3d79 --- /dev/null +++ b/state-manager/tests/unit/controller/test_get_node_run_details.py @@ -0,0 +1,173 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime +from bson import ObjectId +from fastapi import HTTPException + +from app.controller.get_node_run_details import get_node_run_details +from app.models.state_status_enum import StateStatusEnum +from app.models.node_run_details_models import NodeRunDetailsResponse + + +class TestGetNodeRunDetails: + """Test cases for get_node_run_details function""" + + @pytest.mark.asyncio + async def test_get_node_run_details_success(self): + """Test successful node run details retrieval""" + namespace = "test_namespace" + graph_name = "test_graph" + run_id = "test_run_id" + node_id = str(ObjectId()) + request_id = "test_request_id" + + # Create mock state + mock_state = MagicMock() + mock_state.id = ObjectId(node_id) + mock_state.node_name = "test_node" + mock_state.identifier = "test_identifier" + mock_state.graph_name = graph_name + mock_state.run_id = run_id + mock_state.status = StateStatusEnum.SUCCESS + mock_state.inputs = {"input1": "value1"} + mock_state.outputs = {"output1": "result1"} + mock_state.error = None + mock_state.parents = {"parent1": ObjectId()} + mock_state.created_at = datetime.now() + mock_state.updated_at = datetime.now() + + with patch('app.controller.get_node_run_details.State') as mock_state_class: + mock_state_class.find_one = AsyncMock(return_value=mock_state) + + result = await get_node_run_details(namespace, graph_name, run_id, node_id, request_id) + + # Verify the result + assert isinstance(result, NodeRunDetailsResponse) + assert result.id == node_id + assert result.node_name == "test_node" + assert result.identifier == "test_identifier" + assert result.graph_name == graph_name + assert result.run_id == run_id + assert result.status == StateStatusEnum.SUCCESS + assert result.inputs == {"input1": "value1"} + assert result.outputs == {"output1": "result1"} + assert result.error is None + assert len(result.parents) == 1 + + @pytest.mark.asyncio + async def test_get_node_run_details_not_found(self): + """Test node run details when node is not found""" + namespace = "test_namespace" + graph_name = "test_graph" + run_id = "test_run_id" + node_id = str(ObjectId()) + request_id = "test_request_id" + + with patch('app.controller.get_node_run_details.State') as mock_state_class: + mock_state_class.find_one = AsyncMock(return_value=None) + + with pytest.raises(HTTPException) as exc_info: + await get_node_run_details(namespace, graph_name, run_id, node_id, request_id) + + assert exc_info.value.status_code == 404 + assert "not found" in exc_info.value.detail.lower() + + @pytest.mark.asyncio + async def test_get_node_run_details_invalid_node_id(self): + """Test node run details with invalid node ID format""" + namespace = "test_namespace" + graph_name = "test_graph" + run_id = "test_run_id" + node_id = "invalid_id" + request_id = "test_request_id" + + with pytest.raises(HTTPException) as exc_info: + await get_node_run_details(namespace, graph_name, run_id, node_id, request_id) + + assert exc_info.value.status_code == 400 + assert "Invalid node ID format" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_get_node_run_details_with_error(self): + """Test node run details retrieval for a node with error""" + namespace = "test_namespace" + graph_name = "test_graph" + run_id = "test_run_id" + node_id = str(ObjectId()) + request_id = "test_request_id" + + # Create mock state with error + mock_state = MagicMock() + mock_state.id = ObjectId(node_id) + mock_state.node_name = "error_node" + mock_state.identifier = "error_identifier" + mock_state.graph_name = graph_name + mock_state.run_id = run_id + mock_state.status = StateStatusEnum.ERRORED + mock_state.inputs = {"input1": "value1"} + mock_state.outputs = {} + mock_state.error = "Something went wrong" + mock_state.parents = {} + mock_state.created_at = datetime.now() + mock_state.updated_at = datetime.now() + + with patch('app.controller.get_node_run_details.State') as mock_state_class: + mock_state_class.find_one = AsyncMock(return_value=mock_state) + + result = await get_node_run_details(namespace, graph_name, run_id, node_id, request_id) + + # Verify the result + assert result.status == StateStatusEnum.ERRORED + assert result.error == "Something went wrong" + assert result.outputs == {} + + @pytest.mark.asyncio + async def test_get_node_run_details_database_exception(self): + """Test node run details with database exception""" + namespace = "test_namespace" + graph_name = "test_graph" + run_id = "test_run_id" + node_id = str(ObjectId()) + request_id = "test_request_id" + + with patch('app.controller.get_node_run_details.State') as mock_state_class: + mock_state_class.find_one = AsyncMock(side_effect=Exception("Database error")) + + with pytest.raises(HTTPException) as exc_info: + await get_node_run_details(namespace, graph_name, run_id, node_id, request_id) + + assert exc_info.value.status_code == 500 + assert "Internal server error" in exc_info.value.detail + + @pytest.mark.asyncio + async def test_get_node_run_details_empty_timestamps(self): + """Test node run details with empty timestamps""" + namespace = "test_namespace" + graph_name = "test_graph" + run_id = "test_run_id" + node_id = str(ObjectId()) + request_id = "test_request_id" + + # Create mock state with None timestamps + mock_state = MagicMock() + mock_state.id = ObjectId(node_id) + mock_state.node_name = "test_node" + mock_state.identifier = "test_identifier" + mock_state.graph_name = graph_name + mock_state.run_id = run_id + mock_state.status = StateStatusEnum.CREATED + mock_state.inputs = {} + mock_state.outputs = {} + mock_state.error = None + mock_state.parents = {} + mock_state.created_at = None + mock_state.updated_at = None + + with patch('app.controller.get_node_run_details.State') as mock_state_class: + mock_state_class.find_one = AsyncMock(return_value=mock_state) + + result = await get_node_run_details(namespace, graph_name, run_id, node_id, request_id) + + # Verify the result handles None timestamps + assert result.created_at == "" + assert result.updated_at == "" \ No newline at end of file diff --git a/state-manager/tests/unit/test_routes.py b/state-manager/tests/unit/test_routes.py index 881a8862..af50f066 100644 --- a/state-manager/tests/unit/test_routes.py +++ b/state-manager/tests/unit/test_routes.py @@ -48,6 +48,9 @@ def test_router_has_correct_routes(self): assert any('/v0/namespace/{namespace_name}/runs/{page}/{size}' in path for path in paths) assert any('/v0/namespace/{namespace_name}/states/run/{run_id}' in path for path in paths) assert any('/v0/namespace/{namespace_name}/states' in path for path in paths) + + # Node run details route + assert any('/v0/namespace/{namespace_name}/graph/{graph_name}/run/{run_id}/node/{node_id}' in path for path in paths) def test_router_tags(self): """Test that router has correct tags""" @@ -291,7 +294,8 @@ def test_route_handlers_exist(self): list_registered_nodes_route, list_graph_templates_route, get_runs_route, - get_graph_structure_route + get_graph_structure_route, + get_node_run_details_route ) @@ -308,6 +312,7 @@ def test_route_handlers_exist(self): assert callable(list_graph_templates_route) assert callable(get_runs_route) assert callable(get_graph_structure_route) + assert callable(get_node_run_details_route) @@ -996,4 +1001,36 @@ async def test_get_graph_structure_route_with_invalid_api_key(self, mock_get_gra assert exc_info.value.status_code == 401 assert exc_info.value.detail == "Invalid API key" - mock_get_graph_structure.assert_not_called() \ No newline at end of file + mock_get_graph_structure.assert_not_called() + + @patch('app.routes.get_node_run_details') + async def test_get_node_run_details_route_with_valid_api_key(self, mock_get_node_run_details, mock_request): + """Test get_node_run_details_route with valid API key""" + from app.routes import get_node_run_details_route + + # Arrange + mock_get_node_run_details.return_value = MagicMock() + + # Act + result = await get_node_run_details_route("test_namespace", "test_graph", "test_run_id", "test_node_id", mock_request, "valid_key") + + # Assert + mock_get_node_run_details.assert_called_once_with("test_namespace", "test_graph", "test_run_id", "test_node_id", "test-request-id") + assert result == mock_get_node_run_details.return_value + + @patch('app.routes.get_node_run_details') + async def test_get_node_run_details_route_with_invalid_api_key(self, mock_get_node_run_details, mock_request): + """Test get_node_run_details_route with invalid API key""" + from app.routes import get_node_run_details_route + from fastapi import HTTPException + + # Arrange + mock_get_node_run_details.return_value = MagicMock() + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await get_node_run_details_route("test_namespace", "test_graph", "test_run_id", "test_node_id", mock_request, None) # type: ignore + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid API key" + mock_get_node_run_details.assert_not_called() \ No newline at end of file From d09f74b6b66cc5de28ee4c0eb8ee8c0a552df560 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sun, 14 Sep 2025 20:43:00 +0530 Subject: [PATCH 02/11] auth: @nk-ag feat: enhance GraphTemplateDetailModal with custom node visualization and integrate Dark Grotesque font - Added a custom node component for React Flow to improve the visual representation of workflow nodes. - Implemented memoization for node layout calculations to optimize rendering performance. - Updated global styles to include a new custom font, "Dark Grotesque", enhancing the overall UI aesthetics. - Adjusted card components for better layout and spacing in the NodeDetailModal. This commit significantly improves the user experience by providing a more interactive and visually appealing graph structure. --- dashboard/src/app/globals.css | 11 +- .../components/GraphTemplateDetailModal.tsx | 284 +++++++++++++++--- dashboard/src/components/NodeDetailModal.tsx | 2 +- dashboard/src/components/ui/card.tsx | 2 +- 4 files changed, 255 insertions(+), 44 deletions(-) diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css index f977a07f..e8bf2441 100644 --- a/dashboard/src/app/globals.css +++ b/dashboard/src/app/globals.css @@ -3,6 +3,15 @@ @custom-variant dark (&:is(.dark *)); +/* Custom Font Face Declarations */ +@font-face { + font-family: "Dark Grotesque"; + src: url("/DarkerGrotesque-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + :root { --accent: #031035; --accent-light: #0a1a4a; @@ -113,7 +122,7 @@ --color-accent-light: var(--accent-light); --color-accent-lighter: var(--accent-lighter); --color-accent-lightest: var(--accent-lightest); - --font-sans: var(--font-geist-sans); + --font-sans: "Dark Grotesque", sans-serif; --font-mono: var(--font-geist-mono); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); diff --git a/dashboard/src/components/GraphTemplateDetailModal.tsx b/dashboard/src/components/GraphTemplateDetailModal.tsx index dd974374..51295359 100644 --- a/dashboard/src/components/GraphTemplateDetailModal.tsx +++ b/dashboard/src/components/GraphTemplateDetailModal.tsx @@ -1,8 +1,19 @@ 'use client'; -import React from 'react'; +import React, { useMemo } from 'react'; import { UpsertGraphTemplateResponse, NodeTemplate } from '@/types/state-manager'; import { X, GitBranch, Settings, ArrowRight, Key, Code, Database, Workflow, Clock } from 'lucide-react'; +import ReactFlow, { + Node, + Edge, + Controls, + useNodesState, + useEdgesState, + Position, + MarkerType, + Handle +} from 'reactflow'; +import 'reactflow/dist/style.css'; // Shadcn components import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -28,42 +39,223 @@ const RETRY_STRATEGIES = [ { value: 'FIXED_EQUAL_JITTER', label: 'Fixed Equal Jitter' }, ]; +// Custom node component for React Flow +const CustomNode: React.FC<{ data: NodeTemplate & { index: number } }> = ({ data }) => { + return ( +
+ {/* Target Handle (Left side) */} + + + {/* Source Handle (Right side) - only show if node has next_nodes */} + {data.next_nodes && data.next_nodes.length > 0 && ( + + )} + +
+
+ + #{data.index + 1} + +
+
+ +
{data.identifier}
+
{data.node_name}
+ +
+
Namespace: {data.namespace}
+
Inputs: {Object.keys(data.inputs).length}
+
+
+ ); +}; + +// Node types for React Flow +const nodeTypes = { + custom: CustomNode, +}; + const GraphVisualizer: React.FC<{ nodes: NodeTemplate[] }> = ({ nodes }) => { - const renderNode = (node: NodeTemplate, index: number) => { - const connections = node.next_nodes.map(nextNodeId => { - const nextNodeIndex = nodes.findIndex(n => n.identifier === nextNodeId); - return { from: index, to: nextNodeIndex, label: nextNodeId }; + const { flowNodes, flowEdges } = useMemo(() => { + if (!nodes || nodes.length === 0) { + return { flowNodes: [], flowEdges: [] }; + } + + // Create a map of node identifiers for easier lookup + const nodeMap = new Map(nodes.map((node, index) => [node.identifier, { node, index }])); + + // Build adjacency lists for layout calculation + const childrenMap = new Map(); + const parentMap = new Map(); + + // Initialize maps + nodes.forEach(node => { + childrenMap.set(node.identifier, []); + parentMap.set(node.identifier, []); + }); + + // Build relationships based on next_nodes + nodes.forEach((node) => { + if (node.next_nodes && Array.isArray(node.next_nodes)) { + node.next_nodes.forEach((nextNodeId) => { + if (nodeMap.has(nextNodeId)) { + const children = childrenMap.get(node.identifier) || []; + children.push(nextNodeId); + childrenMap.set(node.identifier, children); + + const parents = parentMap.get(nextNodeId) || []; + parents.push(node.identifier); + parentMap.set(nextNodeId, parents); + } + }); + } + }); + + // Find root nodes (nodes with no parents) + const rootNodes = nodes.filter(node => + (parentMap.get(node.identifier) || []).length === 0 + ); + + // Build layers for horizontal layout + const layers: NodeTemplate[][] = []; + const visited = new Set(); + + // Start with root nodes + if (rootNodes.length > 0) { + layers.push(rootNodes); + rootNodes.forEach(node => visited.add(node.identifier)); + } + + // Build layers + let currentLayer = 0; + while (visited.size < nodes.length && currentLayer < nodes.length) { + const currentLayerNodes = layers[currentLayer] || []; + const nextLayer: NodeTemplate[] = []; + + currentLayerNodes.forEach(node => { + const children = childrenMap.get(node.identifier) || []; + children.forEach(childId => { + if (!visited.has(childId)) { + const childNodeData = nodeMap.get(childId); + if (childNodeData && !nextLayer.find(n => n.identifier === childId)) { + nextLayer.push(childNodeData.node); + } + } + }); + }); + + if (nextLayer.length > 0) { + layers.push(nextLayer); + nextLayer.forEach(node => visited.add(node.identifier)); + } + + currentLayer++; + } + + // Add any remaining nodes + const remainingNodes = nodes.filter(node => !visited.has(node.identifier)); + if (remainingNodes.length > 0) { + layers.push(remainingNodes); + } + + // Convert to React Flow nodes with horizontal positioning + const flowNodes: Node[] = []; + const layerWidth = 400; // Horizontal spacing between layers + const nodeHeight = 150; // Vertical spacing between nodes + + layers.forEach((layer, layerIndex) => { + const layerX = layerIndex * layerWidth + 150; + const totalHeight = layer.length * nodeHeight; + const startY = (800 - totalHeight) / 2; // Center vertically + + layer.forEach((node, nodeIndex) => { + const originalIndex = nodeMap.get(node.identifier)?.index || 0; + const y = startY + nodeIndex * nodeHeight + nodeHeight / 2; + + flowNodes.push({ + id: node.identifier, + type: 'custom', + position: { x: layerX, y }, + data: { ...node, index: originalIndex }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + connectable: false, + draggable: false, + }); + }); + }); + + // Create edges based on next_nodes relationships + const flowEdges: Edge[] = []; + nodes.forEach((node) => { + // Ensure next_nodes exists and is an array + if (node.next_nodes && Array.isArray(node.next_nodes)) { + node.next_nodes.forEach((nextNodeId) => { + // Only create edge if target node exists in the graph + if (nodeMap.has(nextNodeId)) { + flowEdges.push({ + id: `${node.identifier}-${nextNodeId}`, + source: node.identifier, + target: nextNodeId, + sourceHandle: 'source', + targetHandle: 'target', + type: 'default', + animated: false, + style: { + stroke: '#87ceeb', + strokeWidth: 2, + strokeDasharray: 'none', + }, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 10, + height: 10, + color: '#87ceeb', + }, + }); + } else { + // Log warning for dangling references (optional - for debugging) + console.warn(`Node "${node.identifier}" references non-existent next node: "${nextNodeId}"`); + } + }); + } }); + return { flowNodes, flowEdges }; + }, [nodes]); + + const [flowNodesState, , onNodesChange] = useNodesState(flowNodes); + const [flowEdgesState, , onEdgesChange] = useEdgesState(flowEdges); + + if (nodes.length === 0) { return ( -
- - -
- {node.identifier} - - {index + 1} - -
-
- -
-
Node: {node.node_name}
-
Namespace: {node.namespace}
-
Inputs: {Object.keys(node.inputs).length}
-
-
-
- - {/* Connection lines */} - {connections.map((connection, connIndex) => ( -
- + + + + + Graph Structure + + Visual representation of the workflow nodes + + +
+ +

No nodes in this graph template.

- ))} -
+ + ); - }; + } return ( @@ -72,19 +264,29 @@ const GraphVisualizer: React.FC<{ nodes: NodeTemplate[] }> = ({ nodes }) => { Graph Structure - Visual representation of the workflow nodes + Interactive visualization of the workflow nodes and their connections -
- {nodes.map((node, index) => renderNode(node, index))} +
+ + +
- - {nodes.length === 0 && ( -
- -

No nodes in this graph template.

-
- )} ); diff --git a/dashboard/src/components/NodeDetailModal.tsx b/dashboard/src/components/NodeDetailModal.tsx index 38e37603..5c1dd95f 100644 --- a/dashboard/src/components/NodeDetailModal.tsx +++ b/dashboard/src/components/NodeDetailModal.tsx @@ -119,7 +119,7 @@ export const NodeDetailModal: React.FC = ({ {/* Header */} -
+
{node.name} diff --git a/dashboard/src/components/ui/card.tsx b/dashboard/src/components/ui/card.tsx index d05bbc6c..c5bc4f48 100644 --- a/dashboard/src/components/ui/card.tsx +++ b/dashboard/src/components/ui/card.tsx @@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
Date: Mon, 15 Sep 2025 10:22:23 +0530 Subject: [PATCH 03/11] feat: enhance GraphVisualization and global styles - Increased font size in globals.css for improved readability. - Refactored loadGraphStructure and loadNodeDetails functions to use useCallback for better performance. - Adjusted node layout parameters in GraphVisualization for enhanced spacing and visual clarity. - Added a Background component to the graph visualization for improved aesthetics. These changes collectively enhance the user experience and visual presentation of the dashboard. auth: @nk-ag --- dashboard/src/app/globals.css | 3 +++ .../src/components/GraphVisualization.tsx | 19 ++++++++++--------- dashboard/src/components/ui/badge.tsx | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css index e8bf2441..5cbabed0 100644 --- a/dashboard/src/app/globals.css +++ b/dashboard/src/app/globals.css @@ -162,6 +162,9 @@ * { @apply border-border outline-ring/50; } + html { + font-size: 25px; /* Increased from default 16px */ + } body { @apply bg-background text-foreground font-sans; } diff --git a/dashboard/src/components/GraphVisualization.tsx b/dashboard/src/components/GraphVisualization.tsx index d790d4ed..3cedb705 100644 --- a/dashboard/src/components/GraphVisualization.tsx +++ b/dashboard/src/components/GraphVisualization.tsx @@ -129,7 +129,7 @@ export const GraphVisualization: React.FC = ({ const [isLoadingNodeDetails, setIsLoadingNodeDetails] = useState(false); const [nodeDetailsError, setNodeDetailsError] = useState(null); - const loadGraphStructure = async () => { + const loadGraphStructure = useCallback(async () => { setIsLoading(true); setError(null); @@ -141,9 +141,9 @@ export const GraphVisualization: React.FC = ({ } finally { setIsLoading(false); } - }; + }, [namespace, runId]); - const loadNodeDetails = async (nodeId: string, graphName: string) => { + const loadNodeDetails = useCallback(async (nodeId: string, graphName: string) => { setIsLoadingNodeDetails(true); setNodeDetailsError(null); @@ -155,13 +155,13 @@ export const GraphVisualization: React.FC = ({ } finally { setIsLoadingNodeDetails(false); } - }; + }, [namespace, runId]); useEffect(() => { if (namespace && runId) { loadGraphStructure(); } - }, [namespace, runId]); + }, [namespace, runId, loadGraphStructure]); // Convert graph data to React Flow format with horizontal layout const { nodes, edges } = useMemo(() => { @@ -239,11 +239,11 @@ export const GraphVisualization: React.FC = ({ // Convert to React Flow nodes with horizontal positioning const reactFlowNodes: Node[] = []; - const layerWidth = 400; // Increased horizontal spacing between layers - const nodeHeight = 150; // Increased vertical spacing between nodes + const layerWidth = 450; // Increased horizontal spacing between layers + const nodeHeight = 250; // Increased vertical spacing between nodes layers.forEach((layer, layerIndex) => { - const layerX = layerIndex * layerWidth + 150; + const layerX = layerIndex * layerWidth + 200; const totalHeight = layer.length * nodeHeight; const startY = (800 - totalHeight) / 2; // Center vertically @@ -309,7 +309,7 @@ export const GraphVisualization: React.FC = ({ if (graphData?.graph_name) { loadNodeDetails(graphNode.id, graphData.graph_name); } - }, [graphData?.graph_name]); + }, [graphData?.graph_name, loadNodeDetails]); if (isLoading) { return ( @@ -427,6 +427,7 @@ export const GraphVisualization: React.FC = ({ nodesConnectable={false} nodesDraggable={false} > +
diff --git a/dashboard/src/components/ui/badge.tsx b/dashboard/src/components/ui/badge.tsx index b9096be5..5695dc12 100644 --- a/dashboard/src/components/ui/badge.tsx +++ b/dashboard/src/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( - "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-bold w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { From 3301313e979b0d8a30207229dc044d8b1d2ba7a1 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 15 Sep 2025 23:26:42 +0530 Subject: [PATCH 04/11] feat: implement namespace management and UI enhancements - Added a new API endpoint to fetch available namespaces, improving data retrieval for the dashboard. - Integrated a Select component for namespace selection in the Dashboard and RunsTable components, enhancing user experience. - Updated the global styles to adjust font size for better readability. - Refactored the Dashboard component to fetch both configuration and namespaces on mount, ensuring a smoother user experience. These changes collectively enhance the functionality and usability of the dashboard. --- dashboard/src/app/api/namespaces/route.ts | 32 +++++++++++++ dashboard/src/app/globals.css | 2 +- dashboard/src/app/page.tsx | 45 ++++++++++++++----- dashboard/src/components/RunsTable.tsx | 5 ++- dashboard/src/components/ui/select.tsx | 27 +++++++++++ dashboard/src/services/clientApi.ts | 9 ++++ dashboard/src/types/state-manager.ts | 5 +++ .../app/controller/list_namespaces.py | 40 +++++++++++++++++ state-manager/app/main.py | 3 +- state-manager/app/models/list_models.py | 6 +++ state-manager/app/routes.py | 33 +++++++++++++- 11 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 dashboard/src/app/api/namespaces/route.ts create mode 100644 dashboard/src/components/ui/select.tsx create mode 100644 state-manager/app/controller/list_namespaces.py diff --git a/dashboard/src/app/api/namespaces/route.ts b/dashboard/src/app/api/namespaces/route.ts new file mode 100644 index 00000000..ba4dcc05 --- /dev/null +++ b/dashboard/src/app/api/namespaces/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE_URL = process.env.EXOSPHERE_STATE_MANAGER_URI || 'http://localhost:8000'; +const API_KEY = process.env.EXOSPHERE_API_KEY; + +export async function GET(request: NextRequest) { + try { + if (!API_KEY) { + return NextResponse.json({ error: 'API key not configured' }, { status: 500 }); + } + + const response = await fetch(`${API_BASE_URL}/v0/namespaces`, { + headers: { + 'X-API-Key': API_KEY, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`State manager API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Error fetching namespaces:', error); + return NextResponse.json( + { error: 'Failed to fetch namespaces' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css index 5cbabed0..fb34f7cd 100644 --- a/dashboard/src/app/globals.css +++ b/dashboard/src/app/globals.css @@ -163,7 +163,7 @@ @apply border-border outline-ring/50; } html { - font-size: 25px; /* Increased from default 16px */ + font-size: 120%; /* Increased from default 16px */ } body { @apply bg-background text-foreground font-sans; diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index e81244c6..4c696d39 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -11,6 +11,7 @@ import { NodeRegistration, UpsertGraphTemplateRequest, UpsertGraphTemplateResponse, + ListNamespacesResponse, } from '@/types/state-manager'; import { GitBranch, @@ -23,12 +24,14 @@ import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Alert, AlertDescription } from '@/components/ui/alert'; export default function Dashboard() { const [activeTab, setActiveTab] = useState< 'overview' | 'graph' |'runs'>('overview'); const [namespace, setNamespace] = useState('default'); + const [availableNamespaces, setAvailableNamespaces] = useState([]); const [graphName, setGraphName] = useState('test-graph'); const [graphTemplate, setGraphTemplate] = useState(null); @@ -41,21 +44,32 @@ export default function Dashboard() { const [selectedGraphTemplate, setSelectedGraphTemplate] = useState(null); const [isGraphModalOpen, setIsGraphModalOpen] = useState(false); - // Fetch configuration on component mount + // Fetch configuration and namespaces on component mount useEffect(() => { - const fetchConfig = async () => { + const fetchConfigAndNamespaces = async () => { try { - const response = await fetch('/api/config'); - if (response.ok) { - const config = await response.json(); + // Fetch configuration + const configResponse = await fetch('/api/config'); + if (configResponse.ok) { + const config = await configResponse.json(); setNamespace(config.defaultNamespace); } + + // Fetch available namespaces + const namespacesData = await clientApiService.getNamespaces(); + setAvailableNamespaces(namespacesData.namespaces || []); + + // If no namespaces available and we have a default, add it + if (namespacesData.namespaces?.length === 0) { + setAvailableNamespaces(['default']); + } } catch (error) { - console.warn('Failed to fetch config, using default namespace'); + console.warn('Failed to fetch config or namespaces, using defaults'); + setAvailableNamespaces(['default']); } }; - fetchConfig(); + fetchConfigAndNamespaces(); }, []); const handleSaveGraphTemplate = async (template: UpsertGraphTemplateRequest) => { @@ -117,12 +131,23 @@ export default function Dashboard() {
Namespace: - setNamespace(e.target.value)} className="w-32 h-8" - /> + > + {availableNamespaces.map((ns) => ( + + ))} + {/* Show current namespace even if not in the list */} + {!availableNamespaces.includes(namespace) && ( + + )} +
diff --git a/dashboard/src/components/RunsTable.tsx b/dashboard/src/components/RunsTable.tsx index 9bd1dc0f..e676a2b0 100644 --- a/dashboard/src/components/RunsTable.tsx +++ b/dashboard/src/components/RunsTable.tsx @@ -24,6 +24,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Select } from './ui/select'; interface RunsTableProps { namespace: string; @@ -193,7 +194,7 @@ export const RunsTable: React.FC = ({ > Auto-refresh: - +
- )} -
- -
- {nodes.map((node, index) => ( -
-
-

- Node {index + 1}: {node.identifier} -

- {!readOnly && ( - - )} -
+ {/* Nodes Section */} +
+
+

Workflow Nodes

+ {!readOnly && ( + + )} +
-
-
- - updateNode(index, { node_name: e.target.value })} - disabled={readOnly} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#031035]" - /> -
+
+ {nodes.map((node, index) => ( +
+
+

Node {index + 1}

+ {!readOnly && ( + + )} +
-
- - updateNode(index, { namespace: e.target.value })} - disabled={readOnly} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#031035]" - /> -
+
+
+ + updateNode(index, { node_name: e.target.value })} + disabled={readOnly} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100" + placeholder="Enter node name" + /> +
-
- - updateNode(index, { identifier: e.target.value })} - disabled={readOnly} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
+
+ + updateNode(index, { namespace: e.target.value })} + disabled={readOnly} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100" + placeholder="Enter namespace" + /> +
-
- - updateNode(index, { - next_nodes: e.target.value.split(',').map(s => s.trim()).filter(Boolean) - })} - disabled={readOnly} - placeholder="node1, node2, node3" - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#031035]" - /> -
-
+
+ + updateNode(index, { identifier: e.target.value })} + disabled={readOnly} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100" + placeholder="Enter unique identifier" + /> +
-
- -