first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View 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>
);
}

View 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>
);
}