외부커넥션관리

This commit is contained in:
leeheejin
2025-09-24 10:04:25 +09:00
parent affb6899cc
commit bc6e6056c1
32 changed files with 6580 additions and 94 deletions

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

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

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

View File

@@ -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>