jskim-node #423
|
|
@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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 { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
ConnectionInfo,
|
||||
ColumnInfo,
|
||||
BatchMappingRequest,
|
||||
NodeFlowItem,
|
||||
} from "@/lib/api/batch";
|
||||
|
||||
export default function BatchCreatePage() {
|
||||
|
|
@ -42,6 +44,11 @@ export default function BatchCreatePage() {
|
|||
// 매핑 상태
|
||||
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
|
||||
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);
|
||||
|
|
@ -52,6 +59,17 @@ export default function BatchCreatePage() {
|
|||
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 () => {
|
||||
setLoadingConnections(true);
|
||||
try {
|
||||
|
|
@ -221,19 +239,28 @@ export default function BatchCreatePage() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (mappings.length === 0) {
|
||||
toast.error("최소 하나 이상의 매핑을 추가해주세요.");
|
||||
return;
|
||||
if (executionType === "mapping") {
|
||||
if (mappings.length === 0) {
|
||||
toast.error("최소 하나 이상의 매핑을 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (selectedFlowId == null) {
|
||||
toast.error("노드 플로우를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const request = {
|
||||
const request: BatchMappingRequest = {
|
||||
batchName: batchName,
|
||||
description: description || undefined,
|
||||
cronSchedule: cronSchedule,
|
||||
mappings: mappings,
|
||||
isActive: true
|
||||
mappings: executionType === "mapping" ? mappings : [],
|
||||
isActive: true,
|
||||
executionType,
|
||||
nodeFlowId: executionType === "node_flow" ? selectedFlowId ?? undefined : undefined,
|
||||
};
|
||||
|
||||
await BatchAPI.createBatchConfig(request);
|
||||
|
|
@ -305,10 +332,66 @@ export default function BatchCreatePage() {
|
|||
rows={3}
|
||||
/>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
{/* 매핑 설정 */}
|
||||
{/* 매핑 설정 - 데이터 매핑일 때만 표시 */}
|
||||
{executionType === "mapping" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* FROM 섹션 */}
|
||||
<Card className="border-emerald-200">
|
||||
|
|
@ -474,9 +557,10 @@ export default function BatchCreatePage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 매핑 현황 */}
|
||||
{mappings.length > 0 && (
|
||||
{executionType === "mapping" && mappings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>컬럼 매핑 현황 ({mappings.length}개)</CardTitle>
|
||||
|
|
@ -529,7 +613,7 @@ export default function BatchCreatePage() {
|
|||
</Button>
|
||||
<Button
|
||||
onClick={saveBatchConfig}
|
||||
disabled={loading || mappings.length === 0}
|
||||
disabled={loading || (executionType === "mapping" ? mappings.length === 0 : selectedFlowId == null)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{loading ? (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
BatchConfig,
|
||||
BatchMapping,
|
||||
ConnectionInfo,
|
||||
NodeFlowItem,
|
||||
} from "@/lib/api/batch";
|
||||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
||||
|
||||
|
|
@ -64,6 +66,11 @@ export default function BatchEditPage() {
|
|||
const [authServiceNames, setAuthServiceNames] = useState<string[]>([]);
|
||||
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 [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
||||
|
|
@ -116,6 +123,17 @@ export default function BatchEditPage() {
|
|||
}
|
||||
}, [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 () => {
|
||||
try {
|
||||
|
|
@ -224,7 +242,9 @@ export default function BatchEditPage() {
|
|||
setConflictKey((config as any).conflict_key || "");
|
||||
setAuthServiceName((config as any).auth_service_name || "");
|
||||
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) {
|
||||
setAuthTokenMode("db");
|
||||
|
|
@ -539,21 +559,33 @@ export default function BatchEditPage() {
|
|||
|
||||
// 배치 설정 저장
|
||||
const saveBatchConfig = async () => {
|
||||
// restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
|
||||
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
|
||||
|
||||
if (!batchName || !cronSchedule || effectiveMappings.length === 0) {
|
||||
if (!batchName || !cronSchedule) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
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 {
|
||||
setLoading(true);
|
||||
|
||||
// 노드 플로우 타입이면 매핑 없이 전송
|
||||
let finalMappings: BatchMapping[] =
|
||||
executionType === "node_flow" ? [] : mappings;
|
||||
|
||||
// restapi-to-db인 경우 mappingList를 mappings 형식으로 변환
|
||||
let finalMappings: BatchMapping[] = 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;
|
||||
finalMappings = mappingList
|
||||
.filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만
|
||||
|
|
@ -586,9 +618,11 @@ export default function BatchEditPage() {
|
|||
isActive,
|
||||
mappings: finalMappings,
|
||||
saveMode,
|
||||
conflictKey: saveMode === "UPSERT" ? conflictKey : null, // INSERT면 null로 명시적 삭제
|
||||
authServiceName: authTokenMode === "db" ? authServiceName : null, // 직접입력이면 null로 명시적 삭제
|
||||
dataArrayPath: dataArrayPath || null
|
||||
conflictKey: saveMode === "UPSERT" ? conflictKey : null,
|
||||
authServiceName: authTokenMode === "db" ? authServiceName : null,
|
||||
dataArrayPath: dataArrayPath || null,
|
||||
executionType,
|
||||
nodeFlowId: executionType === "node_flow" ? selectedFlowId ?? undefined : undefined,
|
||||
});
|
||||
|
||||
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
||||
|
|
@ -683,6 +717,61 @@ export default function BatchEditPage() {
|
|||
/>
|
||||
<Label htmlFor="isActive">활성화</Label>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
|
|
@ -1536,7 +1625,14 @@ export default function BatchEditPage() {
|
|||
</Button>
|
||||
<Button
|
||||
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 ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export interface BatchConfig {
|
|||
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
|
||||
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
|
||||
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_by?: string;
|
||||
updated_date?: Date;
|
||||
|
|
@ -95,6 +98,17 @@ export interface BatchMappingRequest {
|
|||
cronSchedule: string;
|
||||
mappings: BatchMapping[];
|
||||
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> {
|
||||
|
|
@ -460,6 +474,97 @@ export class BatchAPI {
|
|||
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로 정의됨)
|
||||
|
|
|
|||
Loading…
Reference in New Issue