[agent-pipeline] pipe-20260318044621-56k5 round-6

This commit is contained in:
DDD1542 2026-03-18 14:18:02 +09:00
parent 577e9c12d1
commit 27efe672b9
3 changed files with 307 additions and 22 deletions

View File

@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react"; import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -18,6 +19,7 @@ import {
ConnectionInfo, ConnectionInfo,
ColumnInfo, ColumnInfo,
BatchMappingRequest, BatchMappingRequest,
NodeFlowItem,
} from "@/lib/api/batch"; } from "@/lib/api/batch";
export default function BatchCreatePage() { export default function BatchCreatePage() {
@ -43,6 +45,11 @@ export default function BatchCreatePage() {
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null); const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
const [mappings, setMappings] = useState<BatchMapping[]>([]); const [mappings, setMappings] = useState<BatchMapping[]>([]);
// 실행 타입: 데이터 매핑 / 노드 플로우
const [executionType, setExecutionType] = useState<"mapping" | "node_flow">("mapping");
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
const [nodeFlows, setNodeFlows] = useState<NodeFlowItem[]>([]);
// 로딩 상태 // 로딩 상태
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingConnections, setLoadingConnections] = useState(false); const [loadingConnections, setLoadingConnections] = useState(false);
@ -52,6 +59,17 @@ export default function BatchCreatePage() {
loadConnections(); loadConnections();
}, []); }, []);
// 노드 플로우 목록 로드 (실행 타입 노드 플로우 시 사용)
useEffect(() => {
if (executionType !== "node_flow") return;
const load = async () => {
const res = await BatchAPI.getNodeFlows();
if (res.success && res.data) setNodeFlows(res.data);
else setNodeFlows([]);
};
load();
}, [executionType]);
const loadConnections = async () => { const loadConnections = async () => {
setLoadingConnections(true); setLoadingConnections(true);
try { try {
@ -221,19 +239,28 @@ export default function BatchCreatePage() {
return; return;
} }
if (executionType === "mapping") {
if (mappings.length === 0) { if (mappings.length === 0) {
toast.error("최소 하나 이상의 매핑을 추가해주세요."); toast.error("최소 하나 이상의 매핑을 추가해주세요.");
return; return;
} }
} else {
if (selectedFlowId == null) {
toast.error("노드 플로우를 선택해주세요.");
return;
}
}
setLoading(true); setLoading(true);
try { try {
const request = { const request: BatchMappingRequest = {
batchName: batchName, batchName: batchName,
description: description || undefined, description: description || undefined,
cronSchedule: cronSchedule, cronSchedule: cronSchedule,
mappings: mappings, mappings: executionType === "mapping" ? mappings : [],
isActive: true isActive: true,
executionType,
nodeFlowId: executionType === "node_flow" ? selectedFlowId ?? undefined : undefined,
}; };
await BatchAPI.createBatchConfig(request); await BatchAPI.createBatchConfig(request);
@ -305,10 +332,66 @@ export default function BatchCreatePage() {
rows={3} rows={3}
/> />
</div> </div>
{/* 실행 타입 */}
<div className="space-y-3">
<Label> </Label>
<RadioGroup
value={executionType}
onValueChange={(v) => {
setExecutionType(v as "mapping" | "node_flow");
if (v === "mapping") setSelectedFlowId(null);
}}
className="flex flex-col gap-2 sm:flex-row sm:gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mapping" id="exec-mapping" />
<Label htmlFor="exec-mapping" className="font-normal cursor-pointer">
()
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="node_flow" id="exec-node-flow" />
<Label htmlFor="exec-node-flow" className="font-normal cursor-pointer">
</Label>
</div>
</RadioGroup>
{executionType === "node_flow" && (
<div className="space-y-2 pt-2">
<Label> </Label>
<Select
value={selectedFlowId != null ? String(selectedFlowId) : ""}
onValueChange={(v) => setSelectedFlowId(v ? parseInt(v, 10) : null)}
disabled={nodeFlows.length === 0}
>
<SelectTrigger className="w-full max-w-md">
<SelectValue placeholder={nodeFlows.length === 0 ? "로딩 중..." : "플로우를 선택하세요"} />
</SelectTrigger>
<SelectContent>
{nodeFlows.map((flow) => (
<SelectItem key={flow.flow_id} value={String(flow.flow_id)}>
<span className="font-medium">{flow.flow_name}</span>
{flow.description && (
<span className="ml-2 text-muted-foreground text-xs">({flow.description})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedFlowId != null && nodeFlows.find((f) => f.flow_id === selectedFlowId)?.description && (
<p className="text-xs text-muted-foreground">
{nodeFlows.find((f) => f.flow_id === selectedFlowId)?.description}
</p>
)}
</div>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* 매핑 설정 */} {/* 매핑 설정 - 데이터 매핑일 때만 표시 */}
{executionType === "mapping" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* FROM 섹션 */} {/* FROM 섹션 */}
<Card className="border-emerald-200"> <Card className="border-emerald-200">
@ -474,9 +557,10 @@ export default function BatchCreatePage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
)}
{/* 매핑 현황 */} {/* 매핑 현황 */}
{mappings.length > 0 && ( {executionType === "mapping" && mappings.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> ({mappings.length})</CardTitle> <CardTitle> ({mappings.length})</CardTitle>
@ -529,7 +613,7 @@ export default function BatchCreatePage() {
</Button> </Button>
<Button <Button
onClick={saveBatchConfig} onClick={saveBatchConfig}
disabled={loading || mappings.length === 0} disabled={loading || (executionType === "mapping" ? mappings.length === 0 : selectedFlowId == null)}
className="flex items-center space-x-2" className="flex items-center space-x-2"
> >
{loading ? ( {loading ? (

View File

@ -14,6 +14,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react"; import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
@ -23,6 +24,7 @@ import {
BatchConfig, BatchConfig,
BatchMapping, BatchMapping,
ConnectionInfo, ConnectionInfo,
NodeFlowItem,
} from "@/lib/api/batch"; } from "@/lib/api/batch";
import { BatchManagementAPI } from "@/lib/api/batchManagement"; import { BatchManagementAPI } from "@/lib/api/batchManagement";
@ -64,6 +66,11 @@ export default function BatchEditPage() {
const [authServiceNames, setAuthServiceNames] = useState<string[]>([]); const [authServiceNames, setAuthServiceNames] = useState<string[]>([]);
const [dataArrayPath, setDataArrayPath] = useState(""); const [dataArrayPath, setDataArrayPath] = useState("");
// 실행 타입: 데이터 매핑 / 노드 플로우
const [executionType, setExecutionType] = useState<"mapping" | "node_flow">("mapping");
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
const [nodeFlows, setNodeFlows] = useState<NodeFlowItem[]>([]);
// 연결 정보 // 연결 정보
const [connections, setConnections] = useState<ConnectionInfo[]>([]); const [connections, setConnections] = useState<ConnectionInfo[]>([]);
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null); const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
@ -116,6 +123,17 @@ export default function BatchEditPage() {
} }
}, [batchId]); }, [batchId]);
// 노드 플로우 목록 로드 (실행 타입 노드 플로우 시 사용)
useEffect(() => {
if (executionType !== "node_flow") return;
const load = async () => {
const res = await BatchAPI.getNodeFlows();
if (res.success && res.data) setNodeFlows(res.data);
else setNodeFlows([]);
};
load();
}, [executionType]);
// 인증 서비스명 목록 로드 // 인증 서비스명 목록 로드
const loadAuthServiceNames = async () => { const loadAuthServiceNames = async () => {
try { try {
@ -224,6 +242,8 @@ export default function BatchEditPage() {
setConflictKey((config as any).conflict_key || ""); setConflictKey((config as any).conflict_key || "");
setAuthServiceName((config as any).auth_service_name || ""); setAuthServiceName((config as any).auth_service_name || "");
setDataArrayPath((config as any).data_array_path || ""); setDataArrayPath((config as any).data_array_path || "");
setExecutionType((config.execution_type as "mapping" | "node_flow") || "mapping");
setSelectedFlowId(config.node_flow_id ?? null);
// 인증 토큰 모드 설정 // 인증 토큰 모드 설정
if ((config as any).auth_service_name) { if ((config as any).auth_service_name) {
@ -539,21 +559,33 @@ export default function BatchEditPage() {
// 배치 설정 저장 // 배치 설정 저장
const saveBatchConfig = async () => { const saveBatchConfig = async () => {
// restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용 if (!batchName || !cronSchedule) {
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
if (!batchName || !cronSchedule || effectiveMappings.length === 0) {
toast.error("필수 항목을 모두 입력해주세요."); toast.error("필수 항목을 모두 입력해주세요.");
return; return;
} }
if (executionType === "node_flow") {
if (selectedFlowId == null) {
toast.error("노드 플로우를 선택해주세요.");
return;
}
} else {
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
if (effectiveMappings.length === 0) {
toast.error("필수 항목을 모두 입력해주세요.");
return;
}
}
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
try { try {
setLoading(true); setLoading(true);
// restapi-to-db인 경우 mappingList를 mappings 형식으로 변환 // 노드 플로우 타입이면 매핑 없이 전송
let finalMappings: BatchMapping[] = mappings; let finalMappings: BatchMapping[] =
executionType === "node_flow" ? [] : mappings;
if (batchType === "restapi-to-db" && batchConfig?.batch_mappings?.[0]) { if (executionType !== "node_flow" && batchType === "restapi-to-db" && batchConfig?.batch_mappings?.[0]) {
const first = batchConfig.batch_mappings[0] as any; const first = batchConfig.batch_mappings[0] as any;
finalMappings = mappingList finalMappings = mappingList
.filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만 .filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만
@ -586,9 +618,11 @@ export default function BatchEditPage() {
isActive, isActive,
mappings: finalMappings, mappings: finalMappings,
saveMode, saveMode,
conflictKey: saveMode === "UPSERT" ? conflictKey : null, // INSERT면 null로 명시적 삭제 conflictKey: saveMode === "UPSERT" ? conflictKey : null,
authServiceName: authTokenMode === "db" ? authServiceName : null, // 직접입력이면 null로 명시적 삭제 authServiceName: authTokenMode === "db" ? authServiceName : null,
dataArrayPath: dataArrayPath || null dataArrayPath: dataArrayPath || null,
executionType,
nodeFlowId: executionType === "node_flow" ? selectedFlowId ?? undefined : undefined,
}); });
toast.success("배치 설정이 성공적으로 수정되었습니다."); toast.success("배치 설정이 성공적으로 수정되었습니다.");
@ -683,6 +717,61 @@ export default function BatchEditPage() {
/> />
<Label htmlFor="isActive"></Label> <Label htmlFor="isActive"></Label>
</div> </div>
{/* 실행 타입 */}
<div className="space-y-3">
<Label> </Label>
<RadioGroup
value={executionType}
onValueChange={(v) => {
setExecutionType(v as "mapping" | "node_flow");
if (v === "mapping") setSelectedFlowId(null);
}}
className="flex flex-col gap-2 sm:flex-row sm:gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mapping" id="edit-exec-mapping" />
<Label htmlFor="edit-exec-mapping" className="font-normal cursor-pointer">
()
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="node_flow" id="edit-exec-node-flow" />
<Label htmlFor="edit-exec-node-flow" className="font-normal cursor-pointer">
</Label>
</div>
</RadioGroup>
{executionType === "node_flow" && (
<div className="space-y-2 pt-2">
<Label> </Label>
<Select
value={selectedFlowId != null ? String(selectedFlowId) : ""}
onValueChange={(v) => setSelectedFlowId(v ? parseInt(v, 10) : null)}
disabled={nodeFlows.length === 0}
>
<SelectTrigger className="w-full max-w-md">
<SelectValue placeholder={nodeFlows.length === 0 ? "로딩 중..." : "플로우를 선택하세요"} />
</SelectTrigger>
<SelectContent>
{nodeFlows.map((flow) => (
<SelectItem key={flow.flow_id} value={String(flow.flow_id)}>
<span className="font-medium">{flow.flow_name}</span>
{flow.description != null && flow.description !== "" && (
<span className="ml-2 text-muted-foreground text-xs">({flow.description})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedFlowId != null && nodeFlows.find((f) => f.flow_id === selectedFlowId)?.description && (
<p className="text-xs text-muted-foreground">
{nodeFlows.find((f) => f.flow_id === selectedFlowId)?.description}
</p>
)}
</div>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -1536,7 +1625,14 @@ export default function BatchEditPage() {
</Button> </Button>
<Button <Button
onClick={saveBatchConfig} onClick={saveBatchConfig}
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)} disabled={
loading ||
(executionType === "node_flow"
? selectedFlowId == null
: batchType === "restapi-to-db"
? mappingList.length === 0
: mappings.length === 0)
}
> >
{loading ? ( {loading ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" /> <RefreshCw className="mr-2 h-4 w-4 animate-spin" />

View File

@ -13,6 +13,9 @@ export interface BatchConfig {
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT) save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
execution_type?: 'mapping' | 'node_flow';
node_flow_id?: number;
node_flow_context?: Record<string, unknown>;
created_date?: Date; created_date?: Date;
created_by?: string; created_by?: string;
updated_date?: Date; updated_date?: Date;
@ -95,6 +98,17 @@ export interface BatchMappingRequest {
cronSchedule: string; cronSchedule: string;
mappings: BatchMapping[]; mappings: BatchMapping[];
isActive?: boolean; isActive?: boolean;
executionType?: 'mapping' | 'node_flow';
nodeFlowId?: number;
nodeFlowContext?: Record<string, unknown>;
}
/** 노드 플로우 목록 항목 (getNodeFlows 응답) */
export interface NodeFlowItem {
flow_id: number;
flow_name: string;
description?: string;
created_date?: string;
} }
export interface ApiResponse<T> { export interface ApiResponse<T> {
@ -460,6 +474,97 @@ export class BatchAPI {
return []; return [];
} }
} }
/**
*
*/
static async getNodeFlows(): Promise<ApiResponse<NodeFlowItem[]>> {
try {
const response = await apiClient.get<ApiResponse<NodeFlowItem[]>>(
"/batch-management/node-flows"
);
return response.data;
} catch (error) {
console.error("노드 플로우 목록 조회 오류:", error);
return {
success: false,
error: error instanceof Error ? error.message : "노드 플로우 목록 조회에 실패했습니다.",
};
}
}
/**
* ()
*/
static async getBatchStats(): Promise<
ApiResponse<{
totalBatches: number;
activeBatches: number;
todayExecutions: number;
todayFailures: number;
}>
> {
try {
const response = await apiClient.get<
ApiResponse<{
totalBatches: number;
activeBatches: number;
todayExecutions: number;
todayFailures: number;
}>
>("/batch-management/stats");
return response.data;
} catch (error) {
console.error("배치 통계 조회 오류:", error);
return {
success: false,
error: error instanceof Error ? error.message : "배치 통계 조회에 실패했습니다.",
};
}
}
/**
* ( 24 / )
*/
static async getBatchSparkline(
batchId: number
): Promise<
ApiResponse<Array<{ hour: number; status: string; count: number }>>
> {
try {
const response = await apiClient.get<
ApiResponse<Array<{ hour: number; status: string; count: number }>>
>(`/batch-management/batch-configs/${batchId}/sparkline`);
return response.data;
} catch (error) {
console.error("배치 스파크라인 조회 오류:", error);
return {
success: false,
error: error instanceof Error ? error.message : "스파크라인 조회에 실패했습니다.",
};
}
}
/**
*
*/
static async getBatchRecentLogs(
batchId: number,
limit: number = 5
): Promise<ApiResponse<unknown[]>> {
try {
const response = await apiClient.get<ApiResponse<unknown[]>>(
`/batch-management/batch-configs/${batchId}/recent-logs?limit=${limit}`
);
return response.data;
} catch (error) {
console.error("배치 최근 로그 조회 오류:", error);
return {
success: false,
error: error instanceof Error ? error.message : "최근 로그 조회에 실패했습니다.",
};
}
}
} }
// BatchJob export 추가 (이미 위에서 interface로 정의됨) // BatchJob export 추가 (이미 위에서 interface로 정의됨)