'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { ClipboardList, Eye, RefreshCw, Search, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { EmptyStateWithBorder } from '@/components/layout/empty'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { type ApplyLogItem, ApplyLogService } from '@/lib/services/openflare'; import { formatDateTime } from '@/lib/utils'; import { LogDetailSheet } from './components/log-detail-sheet'; const PAGE_SIZE_OPTIONS = [20, 50, 100]; function truncateHash(value: string) { if (!value) return '—'; return value.length > 12 ? `${value.slice(0, 12)}...` : value; } function getResultBadge(result: string) { if (result === 'success') { return ( 成功 ); } if (result === 'warning') { return ( 警告 ); } return ( 失败 ); } export function ApplyLogsPageClient() { const searchParams = useSearchParams(); const initialNodeId = searchParams.get('node_id')?.trim() ?? ''; const [nodeFilterInput, setNodeFilterInput] = useState(initialNodeId); const [nodeFilter, setNodeFilter] = useState(initialNodeId); const [pageNo, setPageNo] = useState(1); const [pageSize, setPageSize] = useState(20); const [rows, setRows] = useState([]); const [current, setCurrent] = useState(1); const [total, setTotal] = useState(0); const [totalPage, setTotalPage] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedLog, setSelectedLog] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [cleanupOpen, setCleanupOpen] = useState(false); const [cleaning, setCleaning] = useState(false); const summary = useMemo(() => { const nodeIds = new Set(rows.map((item) => item.node_id)); return [ { label: '总记录数', value: total }, { label: '当前页', value: current }, { label: '总页数', value: totalPage }, { label: '当前页节点数', value: nodeIds.size }, ]; }, [rows, total, current, totalPage]); const fetchLogs = useCallback(async () => { setLoading(true); setError(null); try { const data = await ApplyLogService.list({ node_id: nodeFilter || undefined, pageNo, pageSize, }); setRows(data.rows); setCurrent(data.current); setTotal(data.total); setTotalPage(data.totalPage); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } finally { setLoading(false); } }, [nodeFilter, pageNo, pageSize]); useEffect(() => { void fetchLogs(); }, [fetchLogs]); useEffect(() => { const nodeId = searchParams.get('node_id')?.trim(); if (!nodeId) return; setNodeFilterInput(nodeId); setNodeFilter(nodeId); setPageNo(1); }, [searchParams]); const handleSearch = () => { setPageNo(1); setNodeFilter(nodeFilterInput.trim()); }; const handleResetFilters = () => { setNodeFilterInput(''); setNodeFilter(''); setPageNo(1); }; const handleCleanupLogs = async () => { setCleaning(true); try { const result = await ApplyLogService.cleanup({ delete_all: true }); toast.success('应用日志已清空', { description: `共删除 ${result.deleted_count} 条记录`, }); setCleanupOpen(false); setPageNo(1); await fetchLogs(); } catch (err) { toast.error('清空应用日志失败', { description: err instanceof Error ? err.message : '未知错误', }); } finally { setCleaning(false); } }; return ( 应用日志 查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。 void fetchLogs()} disabled={loading} > 刷新 setCleanupOpen(true)} disabled={loading || cleaning || total === 0} > 清空日志 返回节点 {summary.map((item) => ( {item.label} {item.value} ))} Node ID setNodeFilterInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} placeholder='输入 node_id 过滤应用日志' className='pl-8 h-9 text-xs' /> 每页条数 { setPageSize(Number.parseInt(value, 10)); setPageNo(1); }} > {PAGE_SIZE_OPTIONS.map((option) => ( {option} 条 ))} 筛选 重置筛选 {error ? ( void fetchLogs()} /> ) : null} {loading ? ( ) : rows.length === 0 ? ( ) : ( Node ID 版本 结果 Checksum 时间 消息 操作 {rows.map((log) => ( {log.node_id} {log.version} {getResultBadge(log.result)} {truncateHash(log.checksum)} {formatDateTime(log.created_at)} {log.message || '—'} { setSelectedLog(log); setDetailOpen(true); }} > 详情 ))} )} {!loading && rows.length > 0 ? ( 第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。 setPageNo((prev) => Math.max(1, prev - 1))} > 上一页 = totalPage} onClick={() => setPageNo((prev) => totalPage > 0 ? Math.min(totalPage, prev + 1) : prev, ) } > 下一页 ) : null} 确认清空应用日志? 该操作将删除全部 {total}{' '} 条应用日志记录,且不可撤销。清空后节点仍会照常上报新的应用结果。 取消 { event.preventDefault(); void handleCleanupLogs(); }} > {cleaning ? '清空中...' : '确认清空'} ); }
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。
{item.label}
{item.value}
Node ID
每页条数
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。