| 'use client'; |
|
|
| import React, { useState, useEffect } from 'react'; |
| import { ServerFunction } from '@/lib/vfs/types'; |
| import { |
| Plus, Loader2, AlertCircle, Wrench, MoreVertical, Pencil, Trash2, |
| ToggleLeft, ToggleRight |
| } from 'lucide-react'; |
| import { Button } from '@/components/ui/button'; |
| import { |
| DropdownMenu, |
| DropdownMenuContent, |
| DropdownMenuItem, |
| DropdownMenuTrigger, |
| } from '@/components/ui/dropdown-menu'; |
| import { ServerFunctionEditor } from './server-function-editor'; |
| import { cn } from '@/lib/utils'; |
| import type { ServerFunctionsDataProvider } from './data-providers'; |
|
|
| interface ServerFunctionsManagerProps { |
| deploymentId?: string; |
| dataProvider?: ServerFunctionsDataProvider; |
| workspaceId?: string; |
| } |
|
|
| export function ServerFunctionsManager({ deploymentId, dataProvider, workspaceId }: ServerFunctionsManagerProps) { |
| const apiBase = workspaceId ? `/api/w/${workspaceId}` : '/api'; |
| const [functions, setFunctions] = useState<ServerFunction[]>([]); |
| const [loading, setLoading] = useState(true); |
| const [error, setError] = useState<string | null>(null); |
| const [editingFunction, setEditingFunction] = useState<ServerFunction | null>(null); |
| const [isCreating, setIsCreating] = useState(false); |
|
|
| useEffect(() => { |
| loadFunctions(); |
| }, [deploymentId, dataProvider]); |
|
|
| const loadFunctions = async () => { |
| try { |
| setLoading(true); |
| setError(null); |
| if (dataProvider) { |
| setFunctions(await dataProvider.list()); |
| } else if (deploymentId) { |
| const res = await fetch(`${apiBase}/admin/deployments/${deploymentId}/server-functions`); |
| if (!res.ok) { |
| const data = await res.json(); |
| throw new Error(data.error || 'Failed to load server functions'); |
| } |
| const data = await res.json(); |
| setFunctions(data.functions); |
| } |
| } catch (err) { |
| setError(err instanceof Error ? err.message : 'Failed to load server functions'); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| const toggleEnabled = async (fn: ServerFunction) => { |
| try { |
| if (dataProvider) { |
| await dataProvider.toggle(fn.id, !fn.enabled); |
| } else if (deploymentId) { |
| const res = await fetch(`${apiBase}/admin/deployments/${deploymentId}/server-functions/${fn.id}`, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ enabled: !fn.enabled }), |
| }); |
| if (!res.ok) throw new Error('Failed to update server function'); |
| } else { |
| return; |
| } |
| await loadFunctions(); |
| } catch (err) { |
| console.error('Failed to toggle server function:', err); |
| } |
| }; |
|
|
| const deleteFunction = async (fn: ServerFunction) => { |
| if (!confirm(`Delete server function "${fn.name}"? This cannot be undone.`)) return; |
|
|
| try { |
| if (dataProvider) { |
| await dataProvider.remove(fn.id); |
| } else if (deploymentId) { |
| const res = await fetch(`${apiBase}/admin/deployments/${deploymentId}/server-functions/${fn.id}`, { |
| method: 'DELETE', |
| }); |
| if (!res.ok) throw new Error('Failed to delete server function'); |
| } else { |
| return; |
| } |
| await loadFunctions(); |
| } catch (err) { |
| console.error('Failed to delete server function:', err); |
| } |
| }; |
|
|
| const handleSave = async (data: Partial<ServerFunction>) => { |
| try { |
| if (dataProvider) { |
| await dataProvider.save(editingFunction?.id || null, data); |
| } else if (!deploymentId) { |
| throw new Error('No deployment ID available'); |
| } else if (editingFunction) { |
| const res = await fetch(`${apiBase}/admin/deployments/${deploymentId}/server-functions/${editingFunction.id}`, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(data), |
| }); |
| if (!res.ok) { |
| const err = await res.json(); |
| throw new Error(err.error || 'Failed to update server function'); |
| } |
| } else { |
| const res = await fetch(`${apiBase}/admin/deployments/${deploymentId}/server-functions`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(data), |
| }); |
| if (!res.ok) { |
| const err = await res.json(); |
| throw new Error(err.error || 'Failed to create server function'); |
| } |
| } |
|
|
| setEditingFunction(null); |
| setIsCreating(false); |
| await loadFunctions(); |
| } catch (err) { |
| throw err; |
| } |
| }; |
|
|
| if (loading) { |
| return ( |
| <div className="flex items-center justify-center h-full"> |
| <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> |
| </div> |
| ); |
| } |
|
|
| if (error) { |
| return ( |
| <div className="flex flex-col items-center justify-center h-full gap-4"> |
| <AlertCircle className="h-8 w-8 text-destructive" /> |
| <p className="text-sm text-muted-foreground">{error}</p> |
| <Button variant="outline" onClick={loadFunctions}> |
| Retry |
| </Button> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="h-full flex flex-col"> |
| <div className="flex items-center justify-between mb-4"> |
| <h3 className="text-sm font-medium">Server Functions (Helpers)</h3> |
| <Button size="sm" onClick={() => setIsCreating(true)}> |
| <Plus className="h-4 w-4 mr-1" /> |
| New Helper |
| </Button> |
| </div> |
| |
| <div className="flex-1 overflow-auto"> |
| {functions.length === 0 ? ( |
| <div className="flex flex-col items-center justify-center h-full p-8 text-center border rounded-lg"> |
| <Wrench className="h-8 w-8 text-muted-foreground mb-2" /> |
| <p className="text-sm text-muted-foreground">No server functions yet</p> |
| <p className="text-xs text-muted-foreground mt-1 mb-4"> |
| Create reusable helpers for your edge functions |
| </p> |
| <Button size="sm" onClick={() => setIsCreating(true)}> |
| <Plus className="h-4 w-4 mr-1" /> |
| Create Helper |
| </Button> |
| </div> |
| ) : ( |
| <div className="grid gap-3"> |
| {functions.map(fn => ( |
| <div |
| key={fn.id} |
| className={cn( |
| "border rounded-lg p-4 transition-colors", |
| !fn.enabled && "opacity-60 bg-muted/30" |
| )} |
| > |
| <div className="flex items-start justify-between gap-2"> |
| <div className="flex-1 min-w-0 overflow-hidden"> |
| <div className="flex items-center gap-2 flex-wrap"> |
| <Wrench className="h-4 w-4 text-orange-500 shrink-0" /> |
| <span className="font-mono font-medium truncate">{fn.name}</span> |
| {!fn.enabled && ( |
| <span className="text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">disabled</span> |
| )} |
| </div> |
| {fn.description && ( |
| <p className="text-sm text-muted-foreground mt-1 truncate"> |
| {fn.description} |
| </p> |
| )} |
| <div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground"> |
| <span className="font-mono truncate">server.{fn.name}(args)</span> |
| </div> |
| </div> |
| |
| <DropdownMenu> |
| <DropdownMenuTrigger asChild> |
| <Button variant="ghost" size="sm"> |
| <MoreVertical className="h-4 w-4" /> |
| </Button> |
| </DropdownMenuTrigger> |
| <DropdownMenuContent align="end"> |
| <DropdownMenuItem onClick={() => setEditingFunction(fn)}> |
| <Pencil className="h-4 w-4 mr-2" /> |
| Edit |
| </DropdownMenuItem> |
| <DropdownMenuItem onClick={() => toggleEnabled(fn)}> |
| {fn.enabled ? ( |
| <> |
| <ToggleLeft className="h-4 w-4 mr-2" /> |
| Disable |
| </> |
| ) : ( |
| <> |
| <ToggleRight className="h-4 w-4 mr-2" /> |
| Enable |
| </> |
| )} |
| </DropdownMenuItem> |
| <DropdownMenuItem |
| onClick={() => deleteFunction(fn)} |
| className="text-destructive" |
| > |
| <Trash2 className="h-4 w-4 mr-2" /> |
| Delete |
| </DropdownMenuItem> |
| </DropdownMenuContent> |
| </DropdownMenu> |
| </div> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
|
|
| {} |
| {(isCreating || editingFunction) && ( |
| <ServerFunctionEditor |
| function={editingFunction} |
| isOpen={true} |
| onClose={() => { |
| setIsCreating(false); |
| setEditingFunction(null); |
| }} |
| onSave={handleSave} |
| /> |
| )} |
| </div> |
| ); |
| } |
|
|