This commit is contained in:
Junyan Qin
2026-03-27 17:22:24 +08:00
parent cad259fe39
commit 244e16c491
12 changed files with 790 additions and 502 deletions
@@ -4,22 +4,12 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, Puzzle, Bug, BarChart3 } from 'lucide-react';
import { Settings, Bug, BarChart3 } from 'lucide-react';
export default function PipelineDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
@@ -39,35 +29,29 @@ export default function PipelineDetailContent({ id }: { id: string }) {
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
function handleFinish() {
refreshPipelines();
}
function handleDeletePipeline() {
httpClient.deletePipeline(id).then(() => {
refreshPipelines();
setShowDeleteConfirm(false);
router.push('/home/pipelines');
});
}
function handleNewPipelineCreated(newPipelineId: string) {
refreshPipelines();
// Navigate to the newly created pipeline's detail view via query param
router.push(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
}
// Create mode: simple form layout
// ==================== Create Mode ====================
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{t('pipelines.createPipeline')}
</h1>
<Button type="submit" form="pipeline-form">
{t('common.submit')}
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
@@ -76,7 +60,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
pipelineId={undefined}
isEditMode={false}
disableForm={false}
showButtons={true}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
@@ -87,115 +71,85 @@ export default function PipelineDetailContent({ id }: { id: string }) {
);
}
// Edit mode: tabbed layout with config, extensions, debug, monitoring
// ==================== Edit Mode ====================
return (
<>
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{t('pipelines.editPipeline')}
</h1>
</div>
<Tabs
key={id}
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0"
>
<TabsList className="shrink-0">
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('pipelines.configuration')}
</TabsTrigger>
<TabsTrigger value="extensions" className="gap-1.5">
<Puzzle className="size-3.5" />
{t('pipelines.extensions.title')}
</TabsTrigger>
<TabsTrigger value="debug" className="gap-1.5">
<Bug className="size-3.5" />
{t('pipelines.debugChat')}
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
</TabsTrigger>
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
</TabsList>
<TabsContent
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineFormComponent
pipelineId={id}
isEditMode={true}
disableForm={false}
showButtons={true}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => setShowDeleteConfirm(true)}
onCancel={() => router.push('/home/pipelines')}
/>
</TabsContent>
<TabsContent
value="extensions"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineExtension pipelineId={id} />
</TabsContent>
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
<DebugDialog
open={activeTab === 'debug'}
pipelineId={id}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
router.push('/home/monitoring');
}}
/>
</TabsContent>
</Tabs>
<div className="flex h-full flex-col">
{/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
<Button type="submit" form="pipeline-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription className="sr-only">
{t('pipelines.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<div className="py-4">{t('pipelines.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={handleDeletePipeline}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
{/* Horizontal Tabs */}
<Tabs
key={id}
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0"
>
<TabsList className="shrink-0">
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('pipelines.configuration')}
</TabsTrigger>
<TabsTrigger value="debug" className="gap-1.5">
<Bug className="size-3.5" />
{t('pipelines.debugChat')}
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
</TabsTrigger>
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
</TabsList>
{/* Tab: Configuration */}
<TabsContent
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineFormComponent
pipelineId={id}
isEditMode={true}
disableForm={false}
showButtons={false}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
onCancel={() => router.push('/home/pipelines')}
onDirtyChange={setFormDirty}
/>
</TabsContent>
{/* Tab: Debug */}
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
<DebugDialog
open={activeTab === 'debug'}
pipelineId={id}
isEmbedded={true}
onConnectionStatusChange={setIsWebSocketConnected}
/>
</TabsContent>
{/* Tab: Monitoring */}
<TabsContent
value="monitoring"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<PipelineMonitoringTab
pipelineId={id}
onNavigateToMonitoring={() => {
router.push('/home/monitoring');
}}
/>
</TabsContent>
</Tabs>
</div>
);
}
@@ -32,7 +32,24 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { cn } from '@/lib/utils';
import { Info, Brain, Zap, Shield, FileOutput } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Info,
Brain,
Zap,
Shield,
FileOutput,
Puzzle,
Trash2,
Copy,
} from 'lucide-react';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
export default function PipelineFormComponent({
onFinish,
@@ -42,6 +59,7 @@ export default function PipelineFormComponent({
showButtons = true,
onDeletePipeline,
onCancel,
onDirtyChange,
}: {
pipelineId?: string;
isEditMode: boolean;
@@ -51,6 +69,7 @@ export default function PipelineFormComponent({
onNewPipelineCreated: (pipelineId: string) => void;
onDeletePipeline: () => void;
onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -93,6 +112,7 @@ export default function PipelineFormComponent({
trigger: Zap,
safety: Shield,
output: FileOutput,
extensions: Puzzle,
};
const formLabelList: SectionItem[] = isEditMode
@@ -122,6 +142,11 @@ export default function PipelineFormComponent({
name: 'output',
icon: SECTION_ICONS.output,
},
{
label: t('pipelines.extensions.title'),
name: 'extensions',
icon: SECTION_ICONS.extensions,
},
]
: [
{
@@ -165,6 +190,11 @@ export default function PipelineFormComponent({
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [isEditMode, watchedValues]);
// Notify parent when dirty state changes
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]);
useEffect(() => {
// get config schema from metadata
httpClient.getGeneralPipelineMetadata().then((resp) => {
@@ -314,27 +344,29 @@ export default function PipelineFormComponent({
// If this is the runner selector stage, render it directly
if (stage.name === 'runner') {
return (
<div key={stage.name} className="space-y-4 mb-6">
<div className="text-lg font-medium">
{extractI18nObject(stage.label)}
</div>
{stage.description && (
<div className="text-sm text-muted-foreground">
{extractI18nObject(stage.description)}
</div>
)}
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
/>
</div>
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
/>
</CardContent>
</Card>
);
}
@@ -346,52 +378,56 @@ export default function PipelineFormComponent({
// For n8n-service-api config, use N8nAuthFormComponent for form linkage
if (stage.name === 'n8n-service-api') {
return (
<div key={stage.name} className="space-y-4 mb-6">
<div className="text-lg font-medium">
{extractI18nObject(stage.label)}
</div>
{stage.description && (
<div className="text-sm text-muted-foreground">
{extractI18nObject(stage.description)}
</div>
)}
<N8nAuthFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
/>
</div>
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<N8nAuthFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
/>
</CardContent>
</Card>
);
}
}
return (
<div key={stage.name} className="space-y-4 mb-6">
<div className="text-lg font-medium">
{extractI18nObject(stage.label)}
</div>
{stage.description && (
<div className="text-sm text-muted-foreground">
{extractI18nObject(stage.description)}
</div>
)}
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
/>
</div>
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
/>
</CardContent>
</Card>
);
}
@@ -477,58 +513,130 @@ export default function PipelineFormComponent({
{/* Basic info section */}
{activeSection === 'basic' && (
<div className="space-y-6">
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Basic Information Card */}
<Card>
<CardHeader>
<CardTitle>{t('pipelines.basicInfo')}</CardTitle>
<CardDescription>
{t('pipelines.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('common.description')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('common.description')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Copy pipeline (edit mode only) */}
{isEditMode && (
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t('pipelines.copyPipelineAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('pipelines.copyPipelineHint')}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopy}
>
<Copy className="size-4 mr-1.5" />
{t('common.copy')}
</Button>
</div>
)}
</CardContent>
</Card>
{/* Danger Zone (edit mode only) */}
{isEditMode && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('pipelines.dangerZone')}
</CardTitle>
<CardDescription>
{t('pipelines.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('pipelines.deletePipelineAction')}
</p>
<p className="text-sm text-muted-foreground">
{isDefaultPipeline
? t('pipelines.defaultPipelineCannotDelete')
: t('pipelines.deletePipelineHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isDefaultPipeline}
onClick={handleDelete}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
)}
</div>
)}
@@ -566,6 +674,10 @@ export default function PipelineFormComponent({
)}
</div>
)}
{activeSection === 'extensions' && pipelineId && (
<PipelineExtension pipelineId={pipelineId} />
)}
</>
)}
</div>