first commit
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
} from "@/components/ui/alertDialog";
|
||||
import { getErrorMessage, useDeletePluginMutation } from "@/lib/store";
|
||||
import { Plugin } from "@/lib/types/plugins";
|
||||
import { AlertDialogTitle } from "@radix-ui/react-alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
plugin: Plugin;
|
||||
}
|
||||
|
||||
export default function ConfirmDeletePluginDialog({ show, onCancel, onDelete, plugin }: Props) {
|
||||
const [deletePlugin, { isLoading: isDeletingPlugin }] = useDeletePluginMutation();
|
||||
|
||||
const onDeleteHandler = () => {
|
||||
deletePlugin(plugin.name)
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
onDelete();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Failed to delete plugin", {
|
||||
description: getErrorMessage(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={show}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Plugin</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the plugin "{plugin.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onDeleteHandler} disabled={isDeletingPlugin}>
|
||||
{isDeletingPlugin ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
137
ui/app/workspace/plugins/fragments/pluginFormFragments.tsx
Normal file
137
ui/app/workspace/plugins/fragments/pluginFormFragments.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Info, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
interface PluginFormData {
|
||||
name: string;
|
||||
path: string;
|
||||
config?: string;
|
||||
hasConfig: boolean;
|
||||
}
|
||||
|
||||
interface PluginFormFragmentProps {
|
||||
form: UseFormReturn<PluginFormData>;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
export function PluginFormFragment({ form, isEditMode = false }: PluginFormFragmentProps) {
|
||||
const [showConfig, setShowConfig] = useState(form.getValues("hasConfig") || false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 flex items-start gap-2 rounded-md border p-3">
|
||||
<Info className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isEditMode
|
||||
? "Update your plugin configuration. Plugin name and path are read-only."
|
||||
: "Install a custom plugin by providing an absolute file path or HTTP URL accessible to Bifrost deployment (.so)."}{" "}
|
||||
<a
|
||||
href="https://docs.getbifrost.ai/plugins"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
data-testid="plugins-form-docs-link"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Plugin Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., my-custom-plugin" {...field} disabled={isEditMode} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Plugin Path/URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., /path/to/plugin.so or https://example.com/plugin.so" {...field} disabled={isEditMode} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!showConfig ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowConfig(true);
|
||||
form.setValue("hasConfig", true);
|
||||
if (!form.getValues("config")) {
|
||||
form.setValue("config", "{}");
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add Configuration
|
||||
</Button>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Configuration (JSON)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowConfig(false);
|
||||
form.setValue("hasConfig", false);
|
||||
form.setValue("config", undefined);
|
||||
}}
|
||||
className="h-auto p-1 text-xs"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="rounded-sm border">
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
minHeight={200}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={field.value || "{}"}
|
||||
lang="json"
|
||||
onChange={field.onChange}
|
||||
options={{
|
||||
scrollBeyondLastLine: false,
|
||||
collapsibleBlocks: true,
|
||||
lineNumbers: "on",
|
||||
alwaysConsumeMouseWheel: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
ui/app/workspace/plugins/layout.tsx
Normal file
16
ui/app/workspace/plugins/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import PluginsPage from "./page";
|
||||
|
||||
function RouteComponent() {
|
||||
const hasPluginsAccess = useRbac(RbacResource.Plugins, RbacOperation.View);
|
||||
if (!hasPluginsAccess) {
|
||||
return <NoPermissionView entity="plugins" />;
|
||||
}
|
||||
return <PluginsPage />;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/workspace/plugins")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
151
ui/app/workspace/plugins/page.tsx
Normal file
151
ui/app/workspace/plugins/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { setSelectedPlugin, useAppDispatch, useAppSelector, useGetPluginsQuery } from "@/lib/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { ListOrdered, PlusIcon, Puzzle } from "lucide-react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AddNewPluginSheet from "./sheets/addNewPluginSheet";
|
||||
import PluginSequenceSheet from "./sheets/pluginSequenceSheet";
|
||||
import { PluginsEmptyState } from "./views/pluginsEmptyState";
|
||||
import PluginsView from "./views/pluginsView";
|
||||
|
||||
export default function PluginsPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const hasCreatePluginAccess = useRbac(RbacResource.Plugins, RbacOperation.Create);
|
||||
const hasUpdatePluginAccess = useRbac(RbacResource.Plugins, RbacOperation.Update);
|
||||
const { data: plugins, isLoading } = useGetPluginsQuery();
|
||||
const selectedPlugin = useAppSelector((state) => state.plugin.selectedPlugin);
|
||||
const [selectedPluginId, setSelectedPluginId] = useQueryState("plugin");
|
||||
const customPlugins = useMemo(() => plugins?.filter((plugin) => plugin.isCustom), [plugins]);
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const [isSequenceSheetOpen, setIsSequenceSheetOpen] = useState(false);
|
||||
|
||||
const handleAddNew = () => {
|
||||
setIsSheetOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseSheet = () => {
|
||||
setIsSheetOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPluginId) return;
|
||||
const plugin = customPlugins?.find((plugin) => plugin.name === selectedPluginId);
|
||||
if (plugin) {
|
||||
dispatch(setSelectedPlugin(plugin));
|
||||
}
|
||||
}, [selectedPluginId, customPlugins]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPluginId) return;
|
||||
if (!selectedPlugin) {
|
||||
setSelectedPluginId(customPlugins?.[0]?.name ?? "");
|
||||
return;
|
||||
}
|
||||
setSelectedPluginId(selectedPlugin?.name ?? "");
|
||||
}, [customPlugins]);
|
||||
|
||||
if (customPlugins?.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<PluginsEmptyState onCreateClick={handleAddNew} canCreate={hasCreatePluginAccess} />
|
||||
<AddNewPluginSheet
|
||||
open={isSheetOpen}
|
||||
onClose={handleCloseSheet}
|
||||
onCreate={(pluginName) => {
|
||||
setSelectedPluginId(pluginName);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex min-w-[250px] flex-col gap-2 pb-10">
|
||||
<div className="rounded-md bg-zinc-50/50 p-4 dark:bg-zinc-800/20">
|
||||
<div className="mb-4">
|
||||
<div className="text-muted-foreground mb-2 text-xs font-medium">Plugins</div>
|
||||
{customPlugins?.map((plugin) => (
|
||||
<button
|
||||
type="button"
|
||||
key={plugin.name}
|
||||
data-testid="plugin-list-item"
|
||||
aria-current={selectedPlugin?.name === plugin.name ? "page" : undefined}
|
||||
className={cn(
|
||||
"mb-1 flex max-h-[32px] w-full items-center gap-2 rounded-sm border px-3 py-1.5 text-sm",
|
||||
selectedPlugin?.name === plugin.name
|
||||
? "bg-secondary opacity-100 hover:opacity-100"
|
||||
: "hover:bg-secondary cursor-pointer border-transparent opacity-100 hover:border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedPluginId(plugin.name);
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-row items-center gap-2">
|
||||
<Puzzle className="text-muted-foreground size-3.5 shrink-0" />
|
||||
<span className="truncate">{plugin.name}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto h-2 w-2 animate-pulse rounded-full",
|
||||
plugin.status?.status === "active" ? "bg-green-800 dark:bg-green-200" : "bg-red-800 dark:bg-red-400",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-4 flex flex-col gap-2">
|
||||
<Button
|
||||
data-testid="plugins-create-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
disabled={!hasCreatePluginAccess}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddNew();
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<div className="text-xs">Install New Plugin</div>
|
||||
</Button>
|
||||
{customPlugins && customPlugins.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
disabled={!hasUpdatePluginAccess}
|
||||
onClick={() => setIsSequenceSheetOpen(true)}
|
||||
data-testid="plugins-sequence-button"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
<div className="text-xs">Edit Plugin Sequence</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PluginsView
|
||||
onDelete={() => {
|
||||
setSelectedPluginId(customPlugins?.[0]?.name ?? "");
|
||||
}}
|
||||
onCreate={(pluginName) => {
|
||||
setSelectedPluginId(pluginName ?? "");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<AddNewPluginSheet
|
||||
open={isSheetOpen}
|
||||
onClose={handleCloseSheet}
|
||||
onCreate={(pluginName) => {
|
||||
setSelectedPluginId(pluginName);
|
||||
}}
|
||||
/>
|
||||
<PluginSequenceSheet open={isSequenceSheetOpen} onClose={() => setIsSequenceSheetOpen(false)} plugins={plugins ?? []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
ui/app/workspace/plugins/sheets/addNewPluginSheet.tsx
Normal file
181
ui/app/workspace/plugins/sheets/addNewPluginSheet.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { getErrorMessage, useCreatePluginMutation, useUpdatePluginMutation } from "@/lib/store";
|
||||
import { Plugin } from "@/lib/types/plugins";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { PluginFormFragment } from "../fragments/pluginFormFragments";
|
||||
|
||||
const pluginFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Plugin name is required")
|
||||
.regex(/^[A-Za-z0-9-_]+$/, "Plugin name must contain only letters, numbers, hyphens, and underscores"),
|
||||
path: z
|
||||
.string()
|
||||
.min(1, "Plugin path/URL is required")
|
||||
.refine(
|
||||
(val) => {
|
||||
// Accept either absolute file paths or HTTP/HTTPS URLs
|
||||
return val.startsWith("/") || val.startsWith("http://") || val.startsWith("https://");
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid absolute file path (starting with /) or HTTP/HTTPS URL",
|
||||
},
|
||||
),
|
||||
hasConfig: z.boolean(),
|
||||
config: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
try {
|
||||
JSON.parse(val);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "Configuration must be valid JSON",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
type PluginFormData = z.infer<typeof pluginFormSchema>;
|
||||
|
||||
interface AddNewPluginSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate?: (pluginName: string) => void;
|
||||
plugin?: Plugin | null;
|
||||
}
|
||||
|
||||
export default function AddNewPluginSheet({ open, onClose, onCreate, plugin }: AddNewPluginSheetProps) {
|
||||
const hasCreatePluginAccess = useRbac(RbacResource.Plugins, RbacOperation.Create);
|
||||
const hasUpdatePluginAccess = useRbac(RbacResource.Plugins, RbacOperation.Update);
|
||||
const [createPlugin, { isLoading: isCreating }] = useCreatePluginMutation();
|
||||
const [updatePlugin, { isLoading: isUpdating }] = useUpdatePluginMutation();
|
||||
|
||||
const isEditMode = !!plugin;
|
||||
const isLoading = isCreating || isUpdating;
|
||||
|
||||
const form = useForm<PluginFormData>({
|
||||
resolver: zodResolver(pluginFormSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
name: "",
|
||||
path: "",
|
||||
hasConfig: false,
|
||||
config: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Load plugin data when editing
|
||||
useEffect(() => {
|
||||
if (plugin) {
|
||||
const hasConfig = plugin.config && Object.keys(plugin.config).length > 0;
|
||||
form.reset({
|
||||
name: plugin.name,
|
||||
path: plugin.path || "",
|
||||
hasConfig,
|
||||
config: hasConfig ? JSON.stringify(plugin.config, null, 2) : undefined,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
name: "",
|
||||
path: "",
|
||||
hasConfig: false,
|
||||
config: undefined,
|
||||
});
|
||||
}
|
||||
}, [plugin, form]);
|
||||
|
||||
const onSubmit = async (data: PluginFormData) => {
|
||||
try {
|
||||
let parsedConfig = {};
|
||||
|
||||
if (data.hasConfig && data.config) {
|
||||
try {
|
||||
parsedConfig = JSON.parse(data.config);
|
||||
} catch {
|
||||
toast.error("Invalid JSON configuration");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditMode && plugin) {
|
||||
// Update existing plugin
|
||||
await updatePlugin({
|
||||
name: plugin.name,
|
||||
data: {
|
||||
enabled: plugin.enabled,
|
||||
config: parsedConfig,
|
||||
},
|
||||
}).unwrap();
|
||||
toast.success("Plugin updated successfully");
|
||||
} else {
|
||||
// Create new plugin
|
||||
await createPlugin({
|
||||
name: data.name,
|
||||
path: data.path,
|
||||
enabled: true,
|
||||
config: parsedConfig,
|
||||
}).unwrap();
|
||||
toast.success("Plugin created successfully");
|
||||
// Notify parent with the config name to select it
|
||||
onCreate?.(data.name);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const disableAction = isEditMode ? !hasUpdatePluginAccess : !hasCreatePluginAccess;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleClose}>
|
||||
<SheetContent className="flex w-full flex-col overflow-x-hidden p-8">
|
||||
<SheetHeader className="flex flex-col items-start p-0">
|
||||
<SheetTitle>{isEditMode ? "Update Plugin" : "Install New Plugin"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isEditMode
|
||||
? "Update the plugin configuration. Note: Plugin name and path cannot be changed."
|
||||
: "Add a custom plugin by providing its name, path/URL, and optional configuration."}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full flex-col gap-6">
|
||||
<div className="flex-1 space-y-4">
|
||||
<PluginFormFragment form={form} isEditMode={isEditMode} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !form.formState.isValid || disableAction} isLoading={isLoading}>
|
||||
{isEditMode ? "Update Plugin" : "Install Plugin"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
186
ui/app/workspace/plugins/sheets/pluginSequenceSheet.tsx
Normal file
186
ui/app/workspace/plugins/sheets/pluginSequenceSheet.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { getErrorMessage, useUpdatePluginMutation } from "@/lib/store";
|
||||
import { Plugin } from "@/lib/types/plugins";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DragDropProvider } from "@dnd-kit/react";
|
||||
import { useSortable } from "@dnd-kit/react/sortable";
|
||||
import { GripVertical, Lock } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const BUILTIN_ID = "__builtin__";
|
||||
|
||||
interface SequenceItem {
|
||||
id: string;
|
||||
type: "builtin" | "custom";
|
||||
plugin?: Plugin;
|
||||
}
|
||||
|
||||
interface PluginSequenceSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
plugins: Plugin[];
|
||||
}
|
||||
|
||||
function buildSequenceItems(plugins: Plugin[]): SequenceItem[] {
|
||||
const customPlugins = plugins.filter((p) => p.isCustom);
|
||||
|
||||
const preBuiltin = customPlugins.filter((p) => p.placement === "pre_builtin").sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const postBuiltin = customPlugins.filter((p) => p.placement !== "pre_builtin").sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
return [
|
||||
...preBuiltin.map((p) => ({ id: p.name, type: "custom" as const, plugin: p })),
|
||||
{ id: BUILTIN_ID, type: "builtin" as const },
|
||||
...postBuiltin.map((p) => ({ id: p.name, type: "custom" as const, plugin: p })),
|
||||
];
|
||||
}
|
||||
|
||||
function SortableBlock({ item, index }: { item: SequenceItem; index: number }) {
|
||||
const isBuiltin = item.type === "builtin";
|
||||
const { ref, isDragging, handleRef, targetRef } = useSortable({
|
||||
id: item.id,
|
||||
index,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isBuiltin ? targetRef : ref}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border px-3 py-2.5 transition-colors",
|
||||
isBuiltin ? "border-dashed bg-zinc-100 dark:bg-zinc-800/50" : "bg-white dark:bg-zinc-900",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{isBuiltin ? (
|
||||
<Lock className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<div ref={handleRef} className="cursor-grab active:cursor-grabbing" data-testid={`plugin-sequence-handle-${item.id}`}>
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
<span className={cn("text-sm", isBuiltin && "text-muted-foreground font-medium")}>
|
||||
{isBuiltin ? "Built-in Plugins" : item.plugin?.name}
|
||||
</span>
|
||||
{!isBuiltin && item.plugin?.status && (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto h-2 w-2 animate-pulse rounded-full",
|
||||
item.plugin.status.status === "active" ? "bg-green-800 dark:bg-green-200" : "bg-red-800 dark:bg-red-400",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginSequenceSheet({ open, onClose, plugins }: PluginSequenceSheetProps) {
|
||||
const [items, setItems] = useState<SequenceItem[]>([]);
|
||||
const [updatePlugin, { isLoading }] = useUpdatePluginMutation();
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !wasOpenRef.current) {
|
||||
setItems(buildSequenceItems(plugins));
|
||||
}
|
||||
wasOpenRef.current = open;
|
||||
}, [open, plugins]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const builtinIndex = items.findIndex((item) => item.type === "builtin");
|
||||
if (builtinIndex === -1) return;
|
||||
|
||||
const updates: { name: string; placement: string; order: number }[] = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
if (item.type !== "custom" || !item.plugin) return;
|
||||
|
||||
const placement = index < builtinIndex ? "pre_builtin" : "post_builtin";
|
||||
const groupItems = items.filter((it, i) => it.type === "custom" && (index < builtinIndex ? i < builtinIndex : i > builtinIndex));
|
||||
const order = groupItems.findIndex((it) => it.id === item.id);
|
||||
|
||||
if (item.plugin.placement !== placement || item.plugin.order !== order) {
|
||||
updates.push({ name: item.plugin.name, placement, order });
|
||||
}
|
||||
});
|
||||
|
||||
if (updates.length === 0) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const u of updates) {
|
||||
const plugin = items.find((i) => i.id === u.name)?.plugin;
|
||||
await updatePlugin({
|
||||
name: u.name,
|
||||
data: {
|
||||
enabled: plugin?.enabled ?? true,
|
||||
config: plugin?.config,
|
||||
path: plugin?.path,
|
||||
placement: u.placement,
|
||||
order: u.order,
|
||||
},
|
||||
}).unwrap();
|
||||
}
|
||||
toast.success("Plugin sequence updated");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [items, updatePlugin, onClose]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onClose}>
|
||||
<SheetContent className="flex w-full flex-col overflow-x-hidden p-8">
|
||||
<SheetHeader className="flex flex-col items-start p-0">
|
||||
<SheetTitle>Edit Plugin Sequence</SheetTitle>
|
||||
<SheetDescription>Drag plugins above or below the built-in plugins block to control execution order.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 flex flex-1 flex-col gap-2">
|
||||
<DragDropProvider
|
||||
onDragOver={(event) => {
|
||||
const { source, target } = event.operation;
|
||||
if (!source || !target || source.id === target.id) return;
|
||||
|
||||
setItems((current) => {
|
||||
const sourceIndex = current.findIndex((item) => item.id === source.id);
|
||||
const targetIndex = current.findIndex((item) => item.id === target.id);
|
||||
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) return current;
|
||||
|
||||
const newItems = [...current];
|
||||
const [movedItem] = newItems.splice(sourceIndex, 1);
|
||||
newItems.splice(targetIndex, 0, movedItem);
|
||||
return newItems;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<SortableBlock key={item.id} item={item} index={index} />
|
||||
))}
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Alert variant="info">
|
||||
<AlertDescription>
|
||||
If your config.json file has plugin sequence configured, it will take precedence over the sequence configured in the UI after
|
||||
restarting Bifrost.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading} data-testid="plugin-sequence-cancel-button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading} isLoading={isLoading} data-testid="plugin-sequence-save-button" type="button">
|
||||
Save Sequence
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
49
ui/app/workspace/plugins/views/pluginsEmptyState.tsx
Normal file
49
ui/app/workspace/plugins/views/pluginsEmptyState.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Puzzle } from "lucide-react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
|
||||
const CUSTOM_PLUGINS_DOCS_URL = "https://docs.getbifrost.ai/plugins";
|
||||
|
||||
interface PluginsEmptyStateProps {
|
||||
onCreateClick: () => void;
|
||||
canCreate?: boolean;
|
||||
}
|
||||
|
||||
export function PluginsEmptyState({ onCreateClick, canCreate = true }: PluginsEmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center"
|
||||
data-testid="plugins-empty-state"
|
||||
>
|
||||
<div className="text-muted-foreground">
|
||||
<Puzzle className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-muted-foreground text-xl font-medium">Custom plugins extend Bifrost with your own business logic</h1>
|
||||
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
|
||||
Build and deploy plugins for custom integrations, workflow automation, and AI governance.
|
||||
</div>
|
||||
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Read more about custom plugins (opens in new tab)"
|
||||
data-testid="plugins-button-read-more"
|
||||
onClick={() => {
|
||||
window.open(`${CUSTOM_PLUGINS_DOCS_URL}?utm_source=bfd`, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Create your first plugin"
|
||||
data-testid="plugins-button-install-new"
|
||||
onClick={onCreateClick}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
Install New Plugin
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
345
ui/app/workspace/plugins/views/pluginsView.tsx
Normal file
345
ui/app/workspace/plugins/views/pluginsView.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import ConfirmDeletePluginDialog from "@/app/workspace/plugins/dialogs/confirmDeletePluginDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeEditor } from "@/components/ui/codeEditor";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { setPluginFormDirtyState, useAppDispatch, useAppSelector, useUpdatePluginMutation } from "@/lib/store";
|
||||
import { PluginType } from "@/lib/types/plugins";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, SaveIcon, Trash2Icon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
interface Props {
|
||||
onDelete: () => void;
|
||||
onCreate: (pluginName: string) => void;
|
||||
}
|
||||
|
||||
const pluginFormSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
enabled: z.boolean(),
|
||||
path: z.string().optional(),
|
||||
config: z.string().optional(),
|
||||
hasConfig: z.boolean(),
|
||||
});
|
||||
|
||||
type PluginFormValues = z.infer<typeof pluginFormSchema>;
|
||||
|
||||
const getPluginTypeColor = (type: PluginType) => {
|
||||
switch (type) {
|
||||
case "llm":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300";
|
||||
case "mcp":
|
||||
return "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300";
|
||||
case "http":
|
||||
return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
export default function PluginsView(props: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const hasUpdatePluginAccess = useRbac(RbacResource.Plugins, RbacOperation.Update);
|
||||
const hasDeletePluginAccess = useRbac(RbacResource.Plugins, RbacOperation.Delete);
|
||||
const [updatePlugin, { isLoading }] = useUpdatePluginMutation();
|
||||
const selectedPlugin = useAppSelector((state) => state.plugin.selectedPlugin);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const form = useForm<PluginFormValues>({
|
||||
resolver: zodResolver(pluginFormSchema),
|
||||
defaultValues: {
|
||||
name: selectedPlugin?.name || "",
|
||||
enabled: selectedPlugin?.enabled || false,
|
||||
path: selectedPlugin?.path || undefined,
|
||||
config: selectedPlugin?.config ? JSON.stringify(selectedPlugin.config, null, 2) : undefined,
|
||||
hasConfig: Boolean(selectedPlugin?.config && Object.keys(selectedPlugin.config).length > 0),
|
||||
},
|
||||
});
|
||||
|
||||
// Update form when selectedPlugin changes
|
||||
useEffect(() => {
|
||||
if (selectedPlugin) {
|
||||
const hasConfig = Boolean(selectedPlugin.config && Object.keys(selectedPlugin.config).length > 0);
|
||||
setShowConfig(hasConfig);
|
||||
form.reset({
|
||||
name: selectedPlugin.name,
|
||||
enabled: selectedPlugin.enabled,
|
||||
path: selectedPlugin.path,
|
||||
config: hasConfig ? JSON.stringify(selectedPlugin.config, null, 2) : undefined,
|
||||
hasConfig,
|
||||
});
|
||||
}
|
||||
}, [selectedPlugin]);
|
||||
|
||||
// Track form dirty state
|
||||
useEffect(() => {
|
||||
const isDirty = form.formState.isDirty;
|
||||
dispatch(setPluginFormDirtyState(isDirty));
|
||||
}, [form.formState.isDirty, dispatch]);
|
||||
|
||||
const onSubmit = async (values: PluginFormValues) => {
|
||||
if (!selectedPlugin) return;
|
||||
|
||||
try {
|
||||
let config;
|
||||
if (values.hasConfig && values.config) {
|
||||
try {
|
||||
config = JSON.parse(values.config);
|
||||
} catch {
|
||||
toast.error("Invalid JSON in configuration");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updatePlugin({
|
||||
name: selectedPlugin.name,
|
||||
data: {
|
||||
enabled: values.enabled,
|
||||
path: values.path ?? undefined,
|
||||
...(config !== undefined && { config }),
|
||||
},
|
||||
}).unwrap();
|
||||
toast.success("Plugin updated successfully");
|
||||
form.reset(values);
|
||||
} catch {
|
||||
toast.error("Failed to update plugin");
|
||||
}
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
toast.error("Please fix the form errors before submitting");
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
const handleDeleteSuccess = () => {
|
||||
setShowDeleteDialog(false);
|
||||
toast.success("Plugin deleted successfully");
|
||||
props.onDelete();
|
||||
};
|
||||
|
||||
if (!selectedPlugin) {
|
||||
return (
|
||||
<div className="ml-4 flex w-full items-center justify-center">
|
||||
<p className="text-muted-foreground">No plugin selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isErrorLog = (log: string) => {
|
||||
const errorKeywords = ["error", "failed", "exception", "panic", "fatal", "ERR"];
|
||||
return errorKeywords.some((keyword) => log.toLowerCase().includes(keyword.toLowerCase()));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-4 w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit, onError)} className="space-y-6">
|
||||
<div className="">
|
||||
<h3 className="mb-4 text-lg font-semibold">Plugin Configuration</h3>
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Plugin name" {...field} readOnly disabled className="cursor-not-allowed" />
|
||||
</FormControl>
|
||||
<FormDescription>The name of the plugin</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedPlugin.status?.types && selectedPlugin.status.types.length > 0 && (
|
||||
<FormItem>
|
||||
<FormLabel>Types</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedPlugin.status.types.map((type) => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="outline"
|
||||
className={cn("h-5 px-2 text-xs font-medium uppercase", getPluginTypeColor(type))}
|
||||
>
|
||||
{type}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
<FormDescription>Enable or disable this plugin</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Plugin path" {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormDescription>The file system path to the plugin</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!showConfig ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowConfig(true);
|
||||
form.setValue("hasConfig", true);
|
||||
if (!form.getValues("config")) {
|
||||
form.setValue("config", "{}");
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add Configuration
|
||||
</Button>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Configuration (JSON)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowConfig(false);
|
||||
form.setValue("hasConfig", false);
|
||||
form.setValue("config", undefined);
|
||||
}}
|
||||
className="h-auto p-1 text-xs"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="rounded-sm border">
|
||||
<CodeEditor
|
||||
className="z-0 w-full"
|
||||
minHeight={200}
|
||||
maxHeight={400}
|
||||
wrap={true}
|
||||
code={field.value || "{}"}
|
||||
lang="json"
|
||||
onChange={field.onChange}
|
||||
options={{
|
||||
scrollBeyondLastLine: false,
|
||||
collapsibleBlocks: true,
|
||||
lineNumbers: "on",
|
||||
alwaysConsumeMouseWheel: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>Plugin configuration in JSON format</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPlugin.status?.status !== "active" && (
|
||||
<div className="mt-4">
|
||||
<div className="space-y-4">
|
||||
{selectedPlugin.status?.logs && selectedPlugin.status.logs.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Logs</label>
|
||||
<div className="rounded-md border px-4 py-2 font-mono text-xs">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{selectedPlugin.status.logs.map((log, index) => (
|
||||
<div key={index} className={isErrorLog(log) ? "text-red-400" : "text-green-600"}>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
className="border-destructive text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={!hasDeletePluginAccess}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
Delete Plugin
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => form.reset()}
|
||||
disabled={!form.formState.isDirty || !hasUpdatePluginAccess}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !form.formState.isDirty || !hasUpdatePluginAccess}>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{selectedPlugin && (
|
||||
<ConfirmDeletePluginDialog
|
||||
show={showDeleteDialog}
|
||||
onCancel={handleDeleteCancel}
|
||||
onDelete={handleDeleteSuccess}
|
||||
plugin={selectedPlugin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user