improvement(deploy): ui/ux improvement, added deletion to API, added handling when inactive

This commit is contained in:
Emir Karabeg
2025-02-28 22:28:43 -08:00
parent 478db6aa2e
commit a4eefd1f64
5 changed files with 433 additions and 90 deletions
+30
View File
@@ -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)
}
}
@@ -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({
<Icon className={cn('h-4 w-4', NotificationColors[type])} />
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{type === 'error' ? 'Error' : 'Console'}</span>
<span className="text-xs font-medium">
{type === 'error' ? 'Error' : type === 'api' ? 'API' : 'Console'}
</span>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>
<p className="text-sm text-foreground">{message}</p>
+57 -8
View File
@@ -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]')}
>
<Rocket className={`h-5 w-5 ${isDeploying ? 'animate-pulse' : ''}`} />
<Rocket className={cn('h-5 w-5', isDeploying && 'animate-rocket-pulse text-[#7F2FFF]')} />
<span className="sr-only">Deploy API</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{isDeploying ? 'Deploying...' : isDeployed ? 'Deployed' : 'Deploy as API Endpoint'}
{isDeploying ? 'Deploying...' : isDeployed ? 'Deployed' : 'Deploy as API'}
</TooltipContent>
</Tooltip>
)
@@ -419,9 +468,9 @@ export function ControlBar() {
{/* Right Section - Actions */}
<div className="flex items-center gap-3">
{renderDeleteButton()}
{renderDeployButton()}
{renderHistoryDropdown()}
{renderNotificationsDropdown()}
{renderDeployButton()}
{renderRunButton()}
</div>
</div>
+328 -76
View File
@@ -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 (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete API Deployment</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this API deployment? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} className="bg-red-600 hover:bg-red-700">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
/**
* 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<Set<string>>(new Set())
const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({})
// 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<typeof setTimeout>[] = []
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 (
<Alert
key={notification.id}
className={cn(
'transition-all duration-300 ease-in-out opacity-0 translate-y-[-100%]',
isFading ? 'animate-notification-fade-out' : 'animate-notification-slide',
NotificationColors[notification.type]
)}
>
<div className="flex items-start gap-4 py-1">
<Icon
className={cn('h-4 w-4', {
'!text-red-500': notification.type === 'error',
'text-foreground': notification.type === 'console',
'!text-green-500': notification.type === 'api',
})}
/>
<div className="flex-1 space-y-2">
<AlertTitle className="flex items-center justify-between -mt-0.5">
<span>
{notification.type === 'error'
? 'Error'
: notification.type === 'api'
? 'API'
: 'Console'}
</span>
{notification.options?.isPersistent && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-transparent hover:text-destructive"
onClick={() => hideNotification(notification.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</AlertTitle>
<AlertDescription className="space-y-4">
<p>{notification.message}</p>
{notification.options?.sections?.map((section, index) => (
<div key={index} className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
{section.label}
</div>
<div
className="relative group cursor-pointer"
onClick={() => handleCopy(notification.id, index, section.content)}
>
<pre className="bg-muted rounded-md p-3 text-xs font-mono whitespace-pre-wrap transition-colors hover:bg-muted/80">
{section.content}
</pre>
<div className="absolute top-3 right-3 text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
{copiedMap[`${notification.id}-${index}`] ? 'Copied!' : 'Click to copy'}
</div>
</div>
</div>
))}
</AlertDescription>
</div>
</div>
</Alert>
)
})}
{visibleNotifications.map((notification) => (
<NotificationAlert
key={notification.id}
notification={notification}
isFading={fadingNotifications.has(notification.id)}
copiedMap={copiedMap}
onHide={hideNotification}
onCopy={handleCopy}
/>
))}
</div>
)
}
/**
* Individual notification alert component
*/
interface NotificationAlertProps {
notification: Notification
isFading: boolean
copiedMap: Record<string, boolean>
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 (
<>
<Alert
className={cn(
'transition-all duration-300 ease-in-out opacity-0 translate-y-[-100%]',
isFading ? 'animate-notification-fade-out' : 'animate-notification-slide',
NotificationColors[type]
)}
>
{type === 'api' ? (
// Special layout for API notifications with equal spacing
<div className="flex items-start py-1">
{/* Left icon */}
<div className="flex-shrink-0 mt-0.5">
<Icon className="!text-[#7F2FFF] h-4 w-4" />
</div>
{/* Content area with equal margins */}
<div className="flex-1 space-y-2 pt-[3.5px] mx-4">
<AlertTitle className="-mt-0.5">
<span>API</span>
</AlertTitle>
<AlertDescription className="space-y-4">
<p>{!isDeployed ? 'Workflow currently not deployed' : message}</p>
{/* Optional sections with copyable content */}
{options?.sections?.map((section, index) => (
<div key={index} className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">{section.label}</div>
{/* Copyable code block */}
<div
className="relative group rounded-md border bg-muted/50 hover:bg-muted/80 transition-colors cursor-pointer"
onClick={() => onCopy(id, index, section.content)}
>
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{section.content}
</pre>
{/* Copy indicator */}
<div className="absolute top-2 right-2 flex items-center gap-1.5">
<div className="text-xs text-muted-foreground bg-background/100 px-2 py-1 rounded-md opacity-0 group-hover:opacity-100 transition-opacity">
{copiedMap[`${id}-${index}`] ? 'Copied!' : 'Click to copy'}
</div>
<Copy className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity bg-muted/100" />
</div>
</div>
</div>
))}
{/* Status and Delete button row - with pulsing green indicator */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">Status:</span>
<div className="flex items-center gap-1.5">
<div className="relative flex items-center justify-center">
{isDeployed ? (
<>
<div className="absolute h-3 w-3 rounded-full bg-green-500/20 animate-ping"></div>
<div className="relative h-2 w-2 rounded-full bg-green-500"></div>
</>
) : (
<>
<div className="absolute h-3 w-3 rounded-full bg-red-500/20 animate-ping"></div>
<div className="relative h-2 w-2 rounded-full bg-red-500"></div>
</>
)}
</div>
<span
className={cn(
'text-xs font-medium',
isDeployed ? ApiStatusStyles.active : ApiStatusStyles.inactive
)}
>
{isDeployed ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteDialogOpen(true)}
disabled={!isDeployed}
>
Delete
</Button>
</div>
</AlertDescription>
</div>
{/* Close button on the right, aligned with header */}
<div className="flex-shrink-0 mt-[-3.5px]">
{options?.isPersistent && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-transparent hover:text-destructive"
onClick={() => onHide(id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
) : (
// Original layout for error and console notifications
<div className="flex items-start gap-4 py-1">
{/* Icon with proper vertical alignment */}
<div className="flex-shrink-0 mt-0.5">
<Icon
className={cn('h-4 w-4', {
'!text-red-500 mt-[-3px]': type === 'error',
'text-foreground mt-[-3px]': type === 'console',
})}
/>
</div>
{/* Content area with right margin for balance */}
<div className="flex-1 space-y-2 mr-4">
<AlertTitle className="flex items-center justify-between -mt-0.5">
<span>{type === 'error' ? 'Error' : 'Console'}</span>
{/* Close button for persistent notifications */}
{options?.isPersistent && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-transparent hover:text-destructive"
onClick={() => onHide(id)}
>
<X className="h-4 w-4" />
</Button>
)}
</AlertTitle>
<AlertDescription className="space-y-4">
<p>{message}</p>
{/* Optional sections with copyable content */}
{options?.sections?.map((section, index) => (
<div key={index} className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">{section.label}</div>
{/* Copyable code block */}
<div
className="relative group rounded-md border bg-muted/50 hover:bg-muted/80 transition-colors cursor-pointer"
onClick={() => onCopy(id, index, section.content)}
>
<pre className="p-3 text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{section.content}
</pre>
{/* Copy indicator */}
<div className="absolute top-2 right-2 flex items-center gap-1.5">
<div className="text-xs text-muted-foreground bg-background/80 px-2 py-1 rounded-md opacity-0 group-hover:opacity-100 transition-opacity">
{copiedMap[`${id}-${index}`] ? 'Copied!' : 'Click to copy'}
</div>
<Copy className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
</div>
))}
</AlertDescription>
</div>
</div>
)}
</Alert>
{/* Delete API confirmation dialog */}
<DeleteApiConfirmation
isOpen={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
onConfirm={handleDeleteApi}
workflowId={workflowId}
/>
</>
)
}
+11 -2
View File
@@ -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',
},
},
},