diff --git a/app/api/workflow/[id]/deploy/route.ts b/app/api/workflow/[id]/deploy/route.ts
index 38bfed672c..64a46fd454 100644
--- a/app/api/workflow/[id]/deploy/route.ts
+++ b/app/api/workflow/[id]/deploy/route.ts
@@ -39,3 +39,33 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(error.message || 'Failed to deploy workflow', 500)
}
}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const { id } = await params
+
+ try {
+ const validation = await validateWorkflowAccess(request, id, false)
+
+ if (validation.error) {
+ return createErrorResponse(validation.error.message, validation.error.status)
+ }
+
+ // Update the workflow to remove deployment
+ await db
+ .update(workflow)
+ .set({
+ apiKey: null,
+ isDeployed: false,
+ deployedAt: null,
+ })
+ .where(eq(workflow.id, id))
+
+ return createSuccessResponse({ isDeployed: false, deployedAt: null, apiKey: null })
+ } catch (error: any) {
+ console.error('Error undeploying workflow:', error)
+ return createErrorResponse(error.message || 'Failed to undeploy workflow', 500)
+ }
+}
diff --git a/app/w/components/control-bar/components/notification-dropdown-item.tsx b/app/w/components/control-bar/components/notification-dropdown-item.tsx
index c3248e1c23..9b43e9ed18 100644
--- a/app/w/components/control-bar/components/notification-dropdown-item.tsx
+++ b/app/w/components/control-bar/components/notification-dropdown-item.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
-import { AlertCircle, Copy, Key, Terminal, X } from 'lucide-react'
+import { AlertCircle, Copy, Rocket, Terminal, X } from 'lucide-react'
import { ErrorIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
@@ -23,13 +23,13 @@ interface NotificationDropdownItemProps {
const NotificationIcon = {
error: ErrorIcon,
console: Terminal,
- api: Key,
+ api: Rocket,
}
const NotificationColors = {
error: 'text-destructive',
console: 'text-foreground',
- api: 'text-green-500',
+ api: 'text-[#7F2FFF]',
}
export function NotificationDropdownItem({
@@ -37,6 +37,7 @@ export function NotificationDropdownItem({
type,
message,
timestamp,
+ options,
}: NotificationDropdownItemProps) {
const { showNotification } = useNotificationStore()
const Icon = NotificationIcon[type]
@@ -58,7 +59,9 @@ export function NotificationDropdownItem({
- {type === 'error' ? 'Error' : 'Console'}
+
+ {type === 'error' ? 'Error' : type === 'api' ? 'API' : 'Console'}
+
{timeAgo}
{message}
diff --git a/app/w/components/control-bar/control-bar.tsx b/app/w/components/control-bar/control-bar.tsx
index 13b706df8f..0bf02b92c8 100644
--- a/app/w/components/control-bar/control-bar.tsx
+++ b/app/w/components/control-bar/control-bar.tsx
@@ -40,7 +40,8 @@ export function ControlBar() {
const router = useRouter()
// Store hooks
- const { notifications, getWorkflowNotifications, addNotification } = useNotificationStore()
+ const { notifications, getWorkflowNotifications, addNotification, showNotification } =
+ useNotificationStore()
const { history, revertToHistoryState, lastSaved, isDeployed, setDeploymentStatus } =
useWorkflowStore()
const { workflows, updateWorkflow, activeWorkflowId, removeWorkflow } = useWorkflowRegistry()
@@ -152,6 +153,57 @@ export function ControlBar() {
*/
const handleDeploy = async () => {
if (!activeWorkflowId) return
+
+ // If already deployed, show the existing deployment info instead of redeploying
+ if (isDeployed) {
+ // Find existing API notification for this workflow
+ const apiNotification = workflowNotifications.find(
+ (n) => n.type === 'api' && n.workflowId === activeWorkflowId
+ )
+
+ if (apiNotification) {
+ // Show the existing notification
+ showNotification(apiNotification.id)
+ return
+ }
+
+ // If notification not found but workflow is deployed, fetch deployment info
+ try {
+ setIsDeploying(true)
+
+ const response = await fetch(`/api/workflow/${activeWorkflowId}/deploy/info`)
+ if (!response.ok) throw new Error('Failed to fetch deployment info')
+
+ const { apiKey } = await response.json()
+ const endpoint = `${process.env.NEXT_PUBLIC_APP_URL}/api/workflow/${activeWorkflowId}/execute`
+
+ // Create a new notification with the deployment info
+ addNotification('api', 'Workflow deployment information', activeWorkflowId, {
+ isPersistent: true,
+ sections: [
+ {
+ label: 'API Endpoint',
+ content: endpoint,
+ },
+ {
+ label: 'API Key',
+ content: apiKey,
+ },
+ {
+ label: 'Example curl command',
+ content: `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}`,
+ },
+ ],
+ })
+ } catch (error) {
+ addNotification('error', 'Failed to fetch deployment information', activeWorkflowId)
+ } finally {
+ setIsDeploying(false)
+ }
+ return
+ }
+
+ // If not deployed, proceed with deployment
try {
setIsDeploying(true)
@@ -277,17 +329,14 @@ export function ControlBar() {
size="icon"
onClick={handleDeploy}
disabled={isDeploying}
- className={cn(
- 'hover:text-foreground',
- isDeployed && 'text-green-500 hover:text-green-500'
- )}
+ className={cn('hover:text-[#7F2FFF]', (isDeployed || isDeploying) && 'text-[#7F2FFF]')}
>
-
+
Deploy API
- {isDeploying ? 'Deploying...' : isDeployed ? 'Deployed' : 'Deploy as API Endpoint'}
+ {isDeploying ? 'Deploying...' : isDeployed ? 'Deployed' : 'Deploy as API'}
)
@@ -419,9 +468,9 @@ export function ControlBar() {
{/* Right Section - Actions */}
{renderDeleteButton()}
- {renderDeployButton()}
{renderHistoryDropdown()}
{renderNotificationsDropdown()}
+ {renderDeployButton()}
{renderRunButton()}
diff --git a/app/w/components/notifications/notifications.tsx b/app/w/components/notifications/notifications.tsx
index 1b23983f2e..c707cc75ec 100644
--- a/app/w/components/notifications/notifications.tsx
+++ b/app/w/components/notifications/notifications.tsx
@@ -1,45 +1,113 @@
import { useEffect, useState } from 'react'
-import { AlertCircle, Copy, Key, Terminal, X } from 'lucide-react'
+import { Copy, Rocket, Terminal, X } from 'lucide-react'
import { ErrorIcon } from '@/components/icons'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useNotificationStore } from '@/stores/notifications/store'
+import { Notification, NotificationType } from '@/stores/notifications/types'
+import { useWorkflowStore } from '@/stores/workflow/store'
+// Constants
const NOTIFICATION_TIMEOUT = 4000
const FADE_DURATION = 300
+// Icon mapping for notification types
const NotificationIcon = {
error: ErrorIcon,
console: Terminal,
- api: Key,
+ api: Rocket,
}
+// Color schemes for different notification types
const NotificationColors = {
error:
'border-red-500 bg-red-50 text-destructive dark:border-border dark:text-foreground dark:bg-background',
console:
'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background',
- api: 'border-green-500 bg-green-50 text-green-700 dark:border-border dark:text-green-500 dark:bg-background',
+ api: 'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background',
}
+// API deployment status styling
+const ApiStatusStyles = {
+ active: 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400',
+ inactive: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400',
+}
+
+/**
+ * AlertDialog component for API deletion confirmation
+ */
+function DeleteApiConfirmation({
+ isOpen,
+ onClose,
+ onConfirm,
+ workflowId,
+}: {
+ isOpen: boolean
+ onClose: () => void
+ onConfirm: () => void
+ workflowId: string | null
+}) {
+ return (
+
+
+
+ Delete API Deployment
+
+ Are you sure you want to delete this API deployment? This action cannot be undone.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+ )
+}
+
+/**
+ * NotificationList component displays all active notifications as alerts
+ * with support for auto-dismissal, copying content, and different styling per type
+ */
export function NotificationList() {
+ // Store access
const { notifications, hideNotification } = useNotificationStore()
+
+ // Local state
const [fadingNotifications, setFadingNotifications] = useState>(new Set())
const [copiedMap, setCopiedMap] = useState>({})
+ // Filter to only show visible notifications
const visibleNotifications = notifications.filter((n) => n.isVisible)
+ // Handle auto-dismissal of non-persistent notifications
useEffect(() => {
+ // Setup timers for each notification
+ const timers: ReturnType[] = []
+
notifications.forEach((notification) => {
+ // Skip if already hidden or marked as persistent
if (!notification.isVisible || notification.options?.isPersistent) return
- // Start fade out
+ // Start fade out animation
const fadeTimer = setTimeout(() => {
setFadingNotifications((prev) => new Set([...prev, notification.id]))
}, NOTIFICATION_TIMEOUT)
- // Hide notification after fade
+ // Hide notification after fade completes
const hideTimer = setTimeout(() => {
hideNotification(notification.id)
setFadingNotifications((prev) => {
@@ -49,21 +117,32 @@ export function NotificationList() {
})
}, NOTIFICATION_TIMEOUT + FADE_DURATION)
- return () => {
- clearTimeout(fadeTimer)
- clearTimeout(hideTimer)
- }
+ timers.push(fadeTimer, hideTimer)
})
+
+ // Cleanup timers on unmount or when notifications change
+ return () => timers.forEach(clearTimeout)
}, [notifications, hideNotification])
+ /**
+ * Handles copying section content to clipboard
+ * @param id - Notification ID
+ * @param sectionIndex - Index of the section being copied
+ * @param content - Text content to copy
+ */
const handleCopy = async (id: string, sectionIndex: number, content: string) => {
await navigator.clipboard.writeText(content)
+
+ // Show "Copied!" indicator
setCopiedMap((prev) => ({ ...prev, [`${id}-${sectionIndex}`]: true }))
+
+ // Reset after 2 seconds
setTimeout(() => {
setCopiedMap((prev) => ({ ...prev, [`${id}-${sectionIndex}`]: false }))
}, 2000)
}
+ // Early return if no notifications to show
if (visibleNotifications.length === 0) return null
return (
@@ -74,73 +153,246 @@ export function NotificationList() {
transform: 'translateX(-50%)',
}}
>
- {visibleNotifications.map((notification) => {
- const Icon = NotificationIcon[notification.type]
- const isFading = fadingNotifications.has(notification.id)
-
- return (
-
-
-
-
-
-
- {notification.type === 'error'
- ? 'Error'
- : notification.type === 'api'
- ? 'API'
- : 'Console'}
-
- {notification.options?.isPersistent && (
-
- )}
-
-
- {notification.message}
- {notification.options?.sections?.map((section, index) => (
-
-
- {section.label}
-
-
handleCopy(notification.id, index, section.content)}
- >
-
- {section.content}
-
-
- {copiedMap[`${notification.id}-${index}`] ? 'Copied!' : 'Click to copy'}
-
-
-
- ))}
-
-
-
-
- )
- })}
+ {visibleNotifications.map((notification) => (
+
+ ))}
)
}
+
+/**
+ * Individual notification alert component
+ */
+interface NotificationAlertProps {
+ notification: Notification
+ isFading: boolean
+ copiedMap: Record
+ onHide: (id: string) => void
+ onCopy: (id: string, sectionIndex: number, content: string) => void
+}
+
+function NotificationAlert({
+ notification,
+ isFading,
+ copiedMap,
+ onHide,
+ onCopy,
+}: NotificationAlertProps) {
+ const { id, type, message, options, workflowId } = notification
+ const Icon = NotificationIcon[type]
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
+ const { setDeploymentStatus } = useWorkflowStore()
+ const { isDeployed } = useWorkflowStore((state) => ({
+ isDeployed: state.isDeployed,
+ }))
+
+ const handleDeleteApi = async () => {
+ if (!workflowId) return
+
+ try {
+ const response = await fetch(`/api/workflow/${workflowId}/deploy`, {
+ method: 'DELETE',
+ })
+
+ if (!response.ok) throw new Error('Failed to delete API deployment')
+
+ // Update deployment status in the store
+ setDeploymentStatus(false)
+
+ // Close the notification
+ onHide(id)
+
+ // Close the dialog
+ setIsDeleteDialogOpen(false)
+ } catch (error) {
+ console.error('Error deleting API deployment:', error)
+ }
+ }
+
+ return (
+ <>
+
+ {type === 'api' ? (
+ // Special layout for API notifications with equal spacing
+
+ {/* Left icon */}
+
+
+
+
+ {/* Content area with equal margins */}
+
+
+ API
+
+
+
+ {!isDeployed ? 'Workflow currently not deployed' : message}
+
+ {/* Optional sections with copyable content */}
+ {options?.sections?.map((section, index) => (
+
+
{section.label}
+
+ {/* Copyable code block */}
+
onCopy(id, index, section.content)}
+ >
+
+ {section.content}
+
+
+ {/* Copy indicator */}
+
+
+ {copiedMap[`${id}-${index}`] ? 'Copied!' : 'Click to copy'}
+
+
+
+
+
+ ))}
+
+ {/* Status and Delete button row - with pulsing green indicator */}
+
+
+
Status:
+
+
+ {isDeployed ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ {isDeployed ? 'Active' : 'Inactive'}
+
+
+
+
+
+
+
+
+ {/* Close button on the right, aligned with header */}
+
+ {options?.isPersistent && (
+
+ )}
+
+
+ ) : (
+ // Original layout for error and console notifications
+
+ {/* Icon with proper vertical alignment */}
+
+
+
+
+ {/* Content area with right margin for balance */}
+
+
+ {type === 'error' ? 'Error' : 'Console'}
+
+ {/* Close button for persistent notifications */}
+ {options?.isPersistent && (
+
+ )}
+
+
+
+ {message}
+
+ {/* Optional sections with copyable content */}
+ {options?.sections?.map((section, index) => (
+
+
{section.label}
+
+ {/* Copyable code block */}
+
onCopy(id, index, section.content)}
+ >
+
+ {section.content}
+
+
+ {/* Copy indicator */}
+
+
+ {copiedMap[`${id}-${index}`] ? 'Copied!' : 'Click to copy'}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Delete API confirmation dialog */}
+ setIsDeleteDialogOpen(false)}
+ onConfirm={handleDeleteApi}
+ workflowId={workflowId}
+ />
+ >
+ )
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 6a7da078a9..25ba069a81 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -73,11 +73,11 @@ export default {
},
'notification-fade-out': {
'0%': {
- opacity: '1',
+ opacity: '0',
transform: 'translateY(0)',
},
'100%': {
- opacity: '0',
+ opacity: '1',
transform: 'translateY(0)',
},
},
@@ -91,12 +91,21 @@ export default {
transform: 'translateY(0)',
},
},
+ 'rocket-pulse': {
+ '0%, 100%': {
+ opacity: '1',
+ },
+ '50%': {
+ opacity: '0.7',
+ },
+ },
},
animation: {
'slide-down': 'slide-down 0.3s ease-out',
'notification-slide': 'notification-slide 0.3s ease-out forwards',
'notification-fade-out': 'notification-fade-out 0.2s ease-out forwards',
'fade-up': 'fade-up 0.5s ease-out forwards',
+ 'rocket-pulse': 'rocket-pulse 1.5s ease-in-out infinite',
},
},
},