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', }, }, },