- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
/**
|
|
* DDL 로그 뷰어 컴포넌트
|
|
* DDL 실행 로그와 통계를 표시
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import { 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 { Badge } from "@/components/ui/badge";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import {
|
|
RefreshCw,
|
|
Search,
|
|
Calendar,
|
|
User,
|
|
Database,
|
|
CheckCircle2,
|
|
XCircle,
|
|
BarChart3,
|
|
Clock,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
import { format } from "date-fns";
|
|
import { ko } from "date-fns/locale";
|
|
import { ddlApi } from "../../lib/api/ddl";
|
|
import { DDLLogViewerProps, DDLExecutionLog, DDLStatistics } from "../../types/ddl";
|
|
|
|
export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|
const [logs, setLogs] = useState<DDLExecutionLog[]>([]);
|
|
const [statistics, setStatistics] = useState<DDLStatistics | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
// 필터 상태
|
|
const [userFilter, setUserFilter] = useState("");
|
|
const [ddlTypeFilter, setDdlTypeFilter] = useState("all");
|
|
const [limit, setLimit] = useState(50);
|
|
|
|
/**
|
|
* 로그 및 통계 로드
|
|
*/
|
|
const loadData = async (showLoading = true) => {
|
|
if (showLoading) setLoading(true);
|
|
setRefreshing(true);
|
|
|
|
try {
|
|
// 로그와 통계를 병렬로 로드
|
|
const [logsResult, statsResult] = await Promise.all([
|
|
ddlApi.getDDLLogs({
|
|
limit,
|
|
userId: userFilter || undefined,
|
|
ddlType: ddlTypeFilter === "all" ? undefined : ddlTypeFilter,
|
|
}),
|
|
ddlApi.getDDLStatistics(),
|
|
]);
|
|
|
|
setLogs(logsResult.logs);
|
|
setStatistics(statsResult);
|
|
} catch (error) {
|
|
// console.error("DDL 로그 로드 실패:", error);
|
|
showErrorToast("DDL 로그를 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
|
} finally {
|
|
if (showLoading) setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 필터 적용
|
|
*/
|
|
const applyFilters = () => {
|
|
loadData(false);
|
|
};
|
|
|
|
/**
|
|
* 필터 초기화
|
|
*/
|
|
const resetFilters = () => {
|
|
setUserFilter("");
|
|
setDdlTypeFilter("");
|
|
setLimit(50);
|
|
};
|
|
|
|
/**
|
|
* 로그 정리
|
|
*/
|
|
const cleanupLogs = async () => {
|
|
if (!confirm("90일 이전의 오래된 로그를 삭제하시겠습니까?")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await ddlApi.cleanupOldLogs(90);
|
|
toast.success(`${result.deletedCount}개의 오래된 로그가 삭제되었습니다.`);
|
|
loadData(false);
|
|
} catch (error) {
|
|
// console.error("로그 정리 실패:", error);
|
|
showErrorToast("로그 정리에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 컴포넌트 마운트 시 데이터 로드
|
|
*/
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadData();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
/**
|
|
* DDL 타입 배지 색상
|
|
*/
|
|
const getDDLTypeBadgeVariant = (ddlType: string) => {
|
|
switch (ddlType) {
|
|
case "CREATE_TABLE":
|
|
return "default";
|
|
case "ADD_COLUMN":
|
|
return "secondary";
|
|
case "DROP_TABLE":
|
|
return "destructive";
|
|
case "DROP_COLUMN":
|
|
return "outline";
|
|
default:
|
|
return "outline";
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 성공률 계산
|
|
*/
|
|
const getSuccessRate = (stats: DDLStatistics) => {
|
|
if (stats.totalExecutions === 0) return 0;
|
|
return Math.round((stats.successfulExecutions / stats.totalExecutions) * 100);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-h-[90vh] max-w-7xl overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Database className="h-5 w-5" />
|
|
DDL 실행 로그 및 통계
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<Tabs defaultValue="logs" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="logs">실행 로그</TabsTrigger>
|
|
<TabsTrigger value="statistics">통계</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 실행 로그 탭 */}
|
|
<TabsContent value="logs" className="space-y-4">
|
|
{/* 필터 및 컨트롤 */}
|
|
<div className="bg-muted/50 flex flex-wrap items-center gap-2 rounded-lg p-4">
|
|
<div className="flex items-center gap-2">
|
|
<User className="h-4 w-4" />
|
|
<Input
|
|
placeholder="사용자 ID"
|
|
value={userFilter}
|
|
onChange={(e) => setUserFilter(e.target.value)}
|
|
className="w-32"
|
|
/>
|
|
</div>
|
|
|
|
<Select value={ddlTypeFilter} onValueChange={setDdlTypeFilter}>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue placeholder="DDL 타입" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
<SelectItem value="CREATE_TABLE">테이블 생성</SelectItem>
|
|
<SelectItem value="ADD_COLUMN">컬럼 추가</SelectItem>
|
|
<SelectItem value="DROP_TABLE">테이블 삭제</SelectItem>
|
|
<SelectItem value="DROP_COLUMN">컬럼 삭제</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={limit.toString()} onValueChange={(value) => setLimit(parseInt(value))}>
|
|
<SelectTrigger className="w-24">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="25">25개</SelectItem>
|
|
<SelectItem value="50">50개</SelectItem>
|
|
<SelectItem value="100">100개</SelectItem>
|
|
<SelectItem value="200">200개</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button onClick={applyFilters} size="sm">
|
|
<Search className="mr-1 h-4 w-4" />
|
|
검색
|
|
</Button>
|
|
|
|
<Button onClick={resetFilters} variant="outline" size="sm">
|
|
초기화
|
|
</Button>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<Button onClick={() => loadData(false)} disabled={refreshing} variant="outline" size="sm">
|
|
<RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
|
새로고침
|
|
</Button>
|
|
|
|
<Button onClick={cleanupLogs} variant="outline" size="sm">
|
|
<Trash2 className="mr-1 h-4 w-4" />
|
|
로그 정리
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 로그 테이블 */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<RefreshCw className="mr-2 h-6 w-6 animate-spin" />
|
|
로그를 불러오는 중...
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>실행 시간</TableHead>
|
|
<TableHead>사용자</TableHead>
|
|
<TableHead>DDL 타입</TableHead>
|
|
<TableHead>테이블명</TableHead>
|
|
<TableHead>결과</TableHead>
|
|
<TableHead>쿼리 미리보기</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{logs.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
|
|
표시할 로그가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
logs.map((log) => (
|
|
<TableRow key={log.id}>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<Clock className="h-3 w-3" />
|
|
{format(new Date(log.executed_at), "yyyy-MM-dd HH:mm:ss", { locale: ko })}
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<Badge variant="outline">{log.user_id}</Badge>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<Badge variant={getDDLTypeBadgeVariant(log.ddl_type)}>{log.ddl_type}</Badge>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<code className="bg-muted rounded px-2 py-1 text-sm">{log.table_name}</code>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
{log.success ? (
|
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="h-4 w-4 text-destructive" />
|
|
)}
|
|
<span className={log.success ? "text-green-600" : "text-destructive"}>
|
|
{log.success ? "성공" : "실패"}
|
|
</span>
|
|
</div>
|
|
{log.error_message && (
|
|
<div className="mt-1 max-w-xs truncate text-xs text-destructive">{log.error_message}</div>
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<code className="text-muted-foreground block max-w-xs truncate text-xs">
|
|
{log.ddl_query_preview}
|
|
</code>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 통계 탭 */}
|
|
<TabsContent value="statistics" className="space-y-4">
|
|
{statistics && (
|
|
<div className="grid gap-4">
|
|
{/* 전체 통계 */}
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">전체 실행</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{statistics.totalExecutions}</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-green-600">{statistics.successfulExecutions}</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-destructive">{statistics.failedExecutions}</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">성공률</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{getSuccessRate(statistics)}%</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* DDL 타입별 통계 */}
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">DDL 타입별 실행 횟수</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{Object.entries(statistics.byDDLType).map(([type, count]) => (
|
|
<div key={type} className="flex items-center justify-between">
|
|
<Badge variant={getDDLTypeBadgeVariant(type)}>{type}</Badge>
|
|
<span className="font-medium">{count}회</span>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">사용자별 실행 횟수</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{Object.entries(statistics.byUser).map(([user, count]) => (
|
|
<div key={user} className="flex items-center justify-between">
|
|
<Badge variant="outline">{user}</Badge>
|
|
<span className="font-medium">{count}회</span>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 최근 실패 로그 */}
|
|
{statistics.recentFailures.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base text-destructive">최근 실패 로그</CardTitle>
|
|
<CardDescription>최근 발생한 DDL 실행 실패 내역입니다.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{statistics.recentFailures.map((failure, index) => (
|
|
<div key={index} className="rounded-lg border border-destructive/20 bg-destructive/10 p-3">
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge>
|
|
<code className="text-sm">{failure.table_name}</code>
|
|
</div>
|
|
<span className="text-muted-foreground text-xs">
|
|
{format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-destructive">{failure.error_message}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|