408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
|
|
/**
|
||
|
|
* DDL 로그 뷰어 컴포넌트
|
||
|
|
* DDL 실행 로그와 통계를 표시
|
||
|
|
*/
|
||
|
|
|
||
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect } from "react";
|
||
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } 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 { 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);
|
||
|
|
toast.error("DDL 로그를 불러오는데 실패했습니다.");
|
||
|
|
} 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);
|
||
|
|
toast.error("로그 정리에 실패했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 컴포넌트 마운트 시 데이터 로드
|
||
|
|
*/
|
||
|
|
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-y-auto">
|
||
|
|
<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-red-600" />
|
||
|
|
)}
|
||
|
|
<span className={log.success ? "text-green-600" : "text-red-600"}>
|
||
|
|
{log.success ? "성공" : "실패"}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
{log.error_message && (
|
||
|
|
<div className="mt-1 max-w-xs truncate text-xs text-red-600">{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-red-600">{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-red-600">최근 실패 로그</CardTitle>
|
||
|
|
<CardDescription>최근 발생한 DDL 실행 실패 내역입니다.</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="space-y-2">
|
||
|
|
{statistics.recentFailures.map((failure, index) => (
|
||
|
|
<div key={index} className="rounded-lg border border-red-200 bg-red-50 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-red-600">{failure.error_message}</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</TabsContent>
|
||
|
|
</Tabs>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|