외부커넥션관리
This commit is contained in:
374
frontend/components/admin/BatchJobModal.tsx
Normal file
374
frontend/components/admin/BatchJobModal.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||
import { CollectionAPI } from "@/lib/api/collection";
|
||||
|
||||
interface BatchJobModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
job?: BatchJob | null;
|
||||
}
|
||||
|
||||
export default function BatchJobModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
job,
|
||||
}: BatchJobModalProps) {
|
||||
const [formData, setFormData] = useState<Partial<BatchJob>>({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: "collection",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
execution_count: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [jobTypes, setJobTypes] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [schedulePresets, setSchedulePresets] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [collectionConfigs, setCollectionConfigs] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadJobTypes();
|
||||
loadSchedulePresets();
|
||||
loadCollectionConfigs();
|
||||
|
||||
if (job) {
|
||||
setFormData({
|
||||
...job,
|
||||
config_json: job.config_json || {},
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: "collection",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
execution_count: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, job]);
|
||||
|
||||
const loadJobTypes = async () => {
|
||||
try {
|
||||
const types = await BatchAPI.getSupportedJobTypes();
|
||||
setJobTypes(types);
|
||||
} catch (error) {
|
||||
console.error("작업 타입 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSchedulePresets = async () => {
|
||||
try {
|
||||
const presets = await BatchAPI.getSchedulePresets();
|
||||
setSchedulePresets(presets);
|
||||
} catch (error) {
|
||||
console.error("스케줄 프리셋 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCollectionConfigs = async () => {
|
||||
try {
|
||||
const configs = await CollectionAPI.getCollectionConfigs({
|
||||
is_active: "Y",
|
||||
});
|
||||
setCollectionConfigs(configs);
|
||||
} catch (error) {
|
||||
console.error("수집 설정 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.job_name || !formData.job_type) {
|
||||
toast.error("필수 필드를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (job?.id) {
|
||||
await BatchAPI.updateBatchJob(job.id, formData);
|
||||
toast.success("배치 작업이 수정되었습니다.");
|
||||
} else {
|
||||
await BatchAPI.createBatchJob(formData as BatchJob);
|
||||
toast.success("배치 작업이 생성되었습니다.");
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("배치 작업 저장 오류:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "배치 작업 저장에 실패했습니다."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchedulePresetSelect = (preset: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
schedule_cron: preset,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleJobTypeChange = (jobType: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
job_type: jobType as any,
|
||||
config_json: {},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCollectionConfigChange = (configId: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
config_json: {
|
||||
...prev.config_json,
|
||||
collectionConfigId: parseInt(configId),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const getJobTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'collection': return '📥';
|
||||
case 'sync': return '🔄';
|
||||
case 'cleanup': return '🧹';
|
||||
case 'custom': return '⚙️';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Y': return 'bg-green-100 text-green-800';
|
||||
case 'N': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{job ? "배치 작업 수정" : "새 배치 작업"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="job_name">작업명 *</Label>
|
||||
<Input
|
||||
id="job_name"
|
||||
value={formData.job_name || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, job_name: e.target.value }))
|
||||
}
|
||||
placeholder="배치 작업명을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="job_type">작업 타입 *</Label>
|
||||
<Select
|
||||
value={formData.job_type || "collection"}
|
||||
onValueChange={handleJobTypeChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{jobTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{getJobTypeIcon(type.value)}</span>
|
||||
{type.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
placeholder="배치 작업에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업 설정 */}
|
||||
{formData.job_type === 'collection' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">수집 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="collection_config">수집 설정</Label>
|
||||
<Select
|
||||
value={formData.config_json?.collectionConfigId?.toString() || ""}
|
||||
onValueChange={handleCollectionConfigChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="수집 설정을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collectionConfigs.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id.toString()}>
|
||||
{config.config_name} - {config.source_table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스케줄 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">스케줄 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule_cron">Cron 표현식</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="schedule_cron"
|
||||
value={formData.schedule_cron || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
|
||||
}
|
||||
placeholder="예: 0 0 * * * (매일 자정)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select onValueChange={handleSchedulePresetSelect}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="프리셋" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schedulePresets.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 실행 통계 (수정 모드일 때만) */}
|
||||
{job?.id && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">실행 통계</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{formData.execution_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">총 실행 횟수</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{formData.success_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">성공 횟수</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{formData.failure_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">실패 횟수</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.last_executed_at && (
|
||||
<div className="text-sm text-gray-600">
|
||||
마지막 실행: {new Date(formData.last_executed_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 활성화 설정 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="is_active">활성화</Label>
|
||||
</div>
|
||||
|
||||
<Badge className={getStatusColor(formData.is_active || "N")}>
|
||||
{formData.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
346
frontend/components/admin/CollectionConfigModal.tsx
Normal file
346
frontend/components/admin/CollectionConfigModal.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
|
||||
interface CollectionConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
config?: DataCollectionConfig | null;
|
||||
}
|
||||
|
||||
export default function CollectionConfigModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
config,
|
||||
}: CollectionConfigModalProps) {
|
||||
const [formData, setFormData] = useState<Partial<DataCollectionConfig>>({
|
||||
config_name: "",
|
||||
description: "",
|
||||
source_connection_id: 0,
|
||||
source_table: "",
|
||||
target_table: "",
|
||||
collection_type: "full",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
collection_options: {},
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
|
||||
const collectionTypeOptions = CollectionAPI.getCollectionTypeOptions();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadConnections();
|
||||
if (config) {
|
||||
setFormData({
|
||||
...config,
|
||||
collection_options: config.collection_options || {},
|
||||
});
|
||||
if (config.source_connection_id) {
|
||||
loadTables(config.source_connection_id);
|
||||
}
|
||||
} else {
|
||||
setFormData({
|
||||
config_name: "",
|
||||
description: "",
|
||||
source_connection_id: 0,
|
||||
source_table: "",
|
||||
target_table: "",
|
||||
collection_type: "full",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
collection_options: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, config]);
|
||||
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
const connectionList = await ExternalDbConnectionAPI.getConnections({
|
||||
is_active: "Y",
|
||||
});
|
||||
setConnections(connectionList);
|
||||
} catch (error) {
|
||||
console.error("외부 연결 목록 조회 오류:", error);
|
||||
toast.error("외부 연결 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async (connectionId: number) => {
|
||||
try {
|
||||
const result = await ExternalDbConnectionAPI.getTables(connectionId);
|
||||
if (result.success && result.data) {
|
||||
setTables(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectionChange = (connectionId: string) => {
|
||||
const id = parseInt(connectionId);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
source_connection_id: id,
|
||||
source_table: "",
|
||||
}));
|
||||
if (id > 0) {
|
||||
loadTables(id);
|
||||
} else {
|
||||
setTables([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.config_name || !formData.source_connection_id || !formData.source_table) {
|
||||
toast.error("필수 필드를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (config?.id) {
|
||||
await CollectionAPI.updateCollectionConfig(config.id, formData);
|
||||
toast.success("수집 설정이 수정되었습니다.");
|
||||
} else {
|
||||
await CollectionAPI.createCollectionConfig(formData as DataCollectionConfig);
|
||||
toast.success("수집 설정이 생성되었습니다.");
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("수집 설정 저장 오류:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "수집 설정 저장에 실패했습니다."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSchedulePresetSelect = (preset: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
schedule_cron: preset,
|
||||
}));
|
||||
};
|
||||
|
||||
const schedulePresets = [
|
||||
{ value: "0 */1 * * *", label: "매시간" },
|
||||
{ value: "0 0 */6 * *", label: "6시간마다" },
|
||||
{ value: "0 0 * * *", label: "매일 자정" },
|
||||
{ value: "0 0 * * 0", label: "매주 일요일" },
|
||||
{ value: "0 0 1 * *", label: "매월 1일" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{config ? "수집 설정 수정" : "새 수집 설정"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="config_name">설정명 *</Label>
|
||||
<Input
|
||||
id="config_name"
|
||||
value={formData.config_name || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, config_name: e.target.value }))
|
||||
}
|
||||
placeholder="수집 설정명을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="collection_type">수집 타입 *</Label>
|
||||
<Select
|
||||
value={formData.collection_type || "full"}
|
||||
onValueChange={(value) =>
|
||||
setFormData(prev => ({ ...prev, collection_type: value as any }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collectionTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
placeholder="수집 설정에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 소스 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">소스 설정</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="source_connection">소스 연결 *</Label>
|
||||
<Select
|
||||
value={formData.source_connection_id?.toString() || ""}
|
||||
onValueChange={handleConnectionChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
{conn.connection_name} ({conn.db_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="source_table">소스 테이블 *</Label>
|
||||
<Select
|
||||
value={formData.source_table || ""}
|
||||
onValueChange={(value) =>
|
||||
setFormData(prev => ({ ...prev, source_table: value }))
|
||||
}
|
||||
disabled={!formData.source_connection_id}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target_table">대상 테이블</Label>
|
||||
<Input
|
||||
id="target_table"
|
||||
value={formData.target_table || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, target_table: e.target.value }))
|
||||
}
|
||||
placeholder="대상 테이블명 (선택사항)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스케줄 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">스케줄 설정</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule_cron">Cron 표현식</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="schedule_cron"
|
||||
value={formData.schedule_cron || ""}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
|
||||
}
|
||||
placeholder="예: 0 0 * * * (매일 자정)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select onValueChange={handleSchedulePresetSelect}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="프리셋" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schedulePresets.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 활성화 설정 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="is_active">활성화</Label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
288
frontend/components/admin/MonitoringDashboard.tsx
Normal file
288
frontend/components/admin/MonitoringDashboard.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchMonitoring, BatchExecution } from "@/lib/api/batch";
|
||||
|
||||
export default function MonitoringDashboard() {
|
||||
const [monitoring, setMonitoring] = useState<BatchMonitoring | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMonitoringData();
|
||||
|
||||
let interval: NodeJS.Timeout;
|
||||
if (autoRefresh) {
|
||||
interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [autoRefresh]);
|
||||
|
||||
const loadMonitoringData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await BatchAPI.getBatchMonitoring();
|
||||
setMonitoring(data);
|
||||
} catch (error) {
|
||||
console.error("모니터링 데이터 조회 오류:", error);
|
||||
toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadMonitoringData();
|
||||
};
|
||||
|
||||
const toggleAutoRefresh = () => {
|
||||
setAutoRefresh(!autoRefresh);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'running':
|
||||
return <Play className="h-4 w-4 text-blue-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants = {
|
||||
completed: "bg-green-100 text-green-800",
|
||||
failed: "bg-red-100 text-red-800",
|
||||
running: "bg-blue-100 text-blue-800",
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
cancelled: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
|
||||
const labels = {
|
||||
completed: "완료",
|
||||
failed: "실패",
|
||||
running: "실행 중",
|
||||
pending: "대기 중",
|
||||
cancelled: "취소됨",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className={variants[status as keyof typeof variants] || variants.pending}>
|
||||
{labels[status as keyof typeof labels] || status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
};
|
||||
|
||||
const getSuccessRate = () => {
|
||||
if (!monitoring) return 0;
|
||||
const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
|
||||
if (total === 0) return 100;
|
||||
return Math.round((monitoring.successful_jobs_today / total) * 100);
|
||||
};
|
||||
|
||||
if (!monitoring) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||
<p>모니터링 데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">배치 모니터링</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleAutoRefresh}
|
||||
className={autoRefresh ? "bg-blue-50 text-blue-600" : ""}
|
||||
>
|
||||
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
||||
자동 새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 작업 수</CardTitle>
|
||||
<div className="text-2xl">📋</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
활성: {monitoring.active_jobs}개
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">실행 중</CardTitle>
|
||||
<div className="text-2xl">🔄</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{monitoring.running_jobs}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
현재 실행 중인 작업
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">오늘 성공</CardTitle>
|
||||
<div className="text-2xl">✅</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
성공률: {getSuccessRate()}%
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">오늘 실패</CardTitle>
|
||||
<div className="text-2xl">❌</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{monitoring.failed_jobs_today}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
주의가 필요한 작업
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 성공률 진행바 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">오늘 실행 성공률</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>성공: {monitoring.successful_jobs_today}건</span>
|
||||
<span>실패: {monitoring.failed_jobs_today}건</span>
|
||||
</div>
|
||||
<Progress value={getSuccessRate()} className="h-2" />
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{getSuccessRate()}% 성공률
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 최근 실행 이력 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">최근 실행 이력</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{monitoring.recent_executions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
최근 실행 이력이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>작업 ID</TableHead>
|
||||
<TableHead>시작 시간</TableHead>
|
||||
<TableHead>완료 시간</TableHead>
|
||||
<TableHead>실행 시간</TableHead>
|
||||
<TableHead>오류 메시지</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monitoring.recent_executions.map((execution) => (
|
||||
<TableRow key={execution.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(execution.execution_status)}
|
||||
{getStatusBadge(execution.execution_status)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">#{execution.job_id}</TableCell>
|
||||
<TableCell>
|
||||
{execution.started_at
|
||||
? new Date(execution.started_at).toLocaleString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{execution.completed_at
|
||||
? new Date(execution.completed_at).toLocaleString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{execution.execution_time_ms
|
||||
? formatDuration(execution.execution_time_ms)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
{execution.error_message ? (
|
||||
<span className="text-red-600 text-sm truncate block">
|
||||
{execution.error_message}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState("");
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [selectedTableColumns, setSelectedTableColumns] = useState<TableColumn[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
@@ -79,6 +81,31 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 테이블 선택 시 컬럼 정보 로딩
|
||||
const loadTableColumns = async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setSelectedTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await ExternalDbConnectionAPI.getTableColumns(connectionId, tableName);
|
||||
if (result.success && result.data) {
|
||||
setSelectedTableColumns(result.data as TableColumn[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 로딩 오류:", error);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "컬럼 정보를 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
console.log("실행 버튼 클릭");
|
||||
if (!query.trim()) {
|
||||
@@ -140,6 +167,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||
value={selectedTable}
|
||||
onValueChange={(value) => {
|
||||
setSelectedTable(value);
|
||||
loadTableColumns(value);
|
||||
// 현재 커서 위치에 테이블 이름 삽입
|
||||
setQuery((prev) => {
|
||||
const fromIndex = prev.toUpperCase().lastIndexOf("FROM");
|
||||
@@ -166,35 +194,72 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||
</div>
|
||||
|
||||
{/* 테이블 정보 */}
|
||||
<div className="bg-muted/50 rounded-md border p-4">
|
||||
<h3 className="mb-2 font-medium">사용 가능한 테이블</h3>
|
||||
<div className="space-y-4 max-h-[300px] overflow-y-auto">
|
||||
<div className="pr-2">
|
||||
{tables.map((table) => (
|
||||
<div key={table.table_name} className="mb-4 bg-white rounded-lg shadow-sm border last:mb-0">
|
||||
<div className="p-3">
|
||||
<div className="bg-muted/50 rounded-md border p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">사용 가능한 테이블</h3>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<div className="pr-2 space-y-2">
|
||||
{tables.map((table) => (
|
||||
<div key={table.table_name} className="bg-white rounded-lg shadow-sm border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-mono font-bold">{table.table_name}</h4>
|
||||
<Button variant="ghost" size="sm" onClick={() => setQuery(`SELECT * FROM ${table.table_name}`)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedTable(table.table_name);
|
||||
loadTableColumns(table.table_name);
|
||||
setQuery(`SELECT * FROM ${table.table_name}`);
|
||||
}}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
</div>
|
||||
{table.description && (
|
||||
<p className="text-muted-foreground mt-1 text-sm">{table.description}</p>
|
||||
)}
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{table.columns.map((column: TableColumn) => (
|
||||
<div key={column.column_name} className="text-sm">
|
||||
<span className="font-mono">{column.column_name}</span>
|
||||
<span className="text-muted-foreground ml-1">({column.data_type})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 테이블의 컬럼 정보 */}
|
||||
{selectedTable && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">테이블 컬럼 정보: {selectedTable}</h3>
|
||||
{loadingColumns ? (
|
||||
<div className="text-sm text-muted-foreground">컬럼 정보 로딩 중...</div>
|
||||
) : selectedTableColumns.length > 0 ? (
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">컬럼명</TableHead>
|
||||
<TableHead className="w-[150px]">데이터 타입</TableHead>
|
||||
<TableHead className="w-[100px]">NULL 허용</TableHead>
|
||||
<TableHead>기본값</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedTableColumns.map((column) => (
|
||||
<TableRow key={column.column_name}>
|
||||
<TableCell className="font-mono font-medium">{column.column_name}</TableCell>
|
||||
<TableCell className="text-sm">{column.data_type}</TableCell>
|
||||
<TableCell className="text-sm">{column.is_nullable}</TableCell>
|
||||
<TableCell className="text-sm">{column.column_default || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">컬럼 정보를 불러올 수 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user