ERP-node/frontend/components/admin/DDLLogViewer.tsx

408 lines
15 KiB
TypeScript
Raw Normal View History

/**
* 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-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>
);
}