From 4433ab5af4375c5f20ed986f64a2fd0d892d3c25 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 18 Mar 2026 14:34:25 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=8A=9F=E8=83=BD]=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=97=A5=E5=BF=97=E5=88=86=E9=A1=B5=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=92=8C=E6=B8=85=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=A5=E5=BF=97=E7=AE=A1=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openflare_server/controller/agent.go | 38 +- openflare_server/model/apply_log.go | 46 +- openflare_server/router/api-router.go | 1 + openflare_server/router/api_phase2_test.go | 46 +- openflare_server/service/agent.go | 102 ++- .../web/features/apply-logs/api/apply-logs.ts | 32 +- .../apply-logs/components/apply-logs-page.tsx | 857 +++++++++++------- .../web/features/apply-logs/types.ts | 25 + .../nodes/components/node-detail-page.tsx | 13 +- 9 files changed, 790 insertions(+), 370 deletions(-) diff --git a/openflare_server/controller/agent.go b/openflare_server/controller/agent.go index 10952631..c342e3a7 100644 --- a/openflare_server/controller/agent.go +++ b/openflare_server/controller/agent.go @@ -3,6 +3,7 @@ package controller import ( "openflare/model" "openflare/service" + "strconv" "github.com/gin-gonic/gin" ) @@ -144,10 +145,45 @@ func GetNodes(c *gin.Context) { // @Success 200 {object} map[string]interface{} // @Router /api/apply-logs/ [get] func GetApplyLogs(c *gin.Context) { - logs, err := service.ListApplyLogs(c.Query("node_id")) + logs, err := service.ListApplyLogsPage(service.ApplyLogListQuery{ + NodeID: c.Query("node_id"), + PageNo: readIntQueryFallback(c, "pageNo", "page_no"), + PageSize: readIntQueryFallback(c, "pageSize", "page_size"), + }) if err != nil { respondFailure(c, err.Error()) return } respondSuccess(c, logs) } + +// CleanupApplyLogs godoc +// @Summary Cleanup apply logs +// @Tags ApplyLogs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} +// @Router /api/apply-logs/cleanup [post] +func CleanupApplyLogs(c *gin.Context) { + var input service.ApplyLogCleanupInput + if err := c.ShouldBindJSON(&input); err != nil { + respondBadRequest(c, "") + return + } + result, err := service.CleanupApplyLogs(input) + if err != nil { + respondFailure(c, err.Error()) + return + } + respondSuccess(c, result) +} + +func readIntQueryFallback(c *gin.Context, primary string, secondary string) int { + value := c.Query(primary) + if value == "" { + value = c.Query(secondary) + } + parsed, _ := strconv.Atoi(value) + return parsed +} diff --git a/openflare_server/model/apply_log.go b/openflare_server/model/apply_log.go index 6fb6af09..545bbcbc 100644 --- a/openflare_server/model/apply_log.go +++ b/openflare_server/model/apply_log.go @@ -1,6 +1,16 @@ package model -import "time" +import ( + "time" + + "gorm.io/gorm" +) + +type ApplyLogQuery struct { + NodeID string + PageNo int + PageSize int +} type ApplyLog struct { ID uint `json:"id" gorm:"primaryKey"` @@ -15,13 +25,29 @@ type ApplyLog struct { CreatedAt time.Time `json:"created_at"` } -func ListApplyLogs(nodeID string) (logs []*ApplyLog, err error) { - query := DB.Order("id desc") +func ListApplyLogs(query ApplyLogQuery) (logs []*ApplyLog, err error) { + db := DB.Order("id desc") + if query.NodeID != "" { + db = db.Where("node_id = ?", query.NodeID) + } + if query.PageSize > 0 { + offset := 0 + if query.PageNo > 1 { + offset = (query.PageNo - 1) * query.PageSize + } + db = db.Limit(query.PageSize).Offset(offset) + } + err = db.Find(&logs).Error + return logs, err +} + +func CountApplyLogs(nodeID string) (total int64, err error) { + query := DB.Model(&ApplyLog{}) if nodeID != "" { query = query.Where("node_id = ?", nodeID) } - err = query.Find(&logs).Error - return logs, err + err = query.Count(&total).Error + return total, err } func GetLatestApplyLog(nodeID string) (*ApplyLog, error) { @@ -49,3 +75,13 @@ func GetLatestApplyLogsByNodeIDs(nodeIDs []string) (map[string]*ApplyLog, error) } return result, nil } + +func DeleteAllApplyLogs() (deleted int64, err error) { + result := DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ApplyLog{}) + return result.RowsAffected, result.Error +} + +func DeleteApplyLogsBefore(before time.Time) (deleted int64, err error) { + result := DB.Where("created_at < ?", before).Delete(&ApplyLog{}) + return result.RowsAffected, result.Error +} diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 7424b9c0..eed9ab30 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -134,6 +134,7 @@ func SetApiRouter(router *gin.Engine) { applyLogRoute.Use(middleware.AdminAuth()) { applyLogRoute.GET("/", controller.GetApplyLogs) + applyLogRoute.POST("/cleanup", controller.CleanupApplyLogs) } accessLogRoute := apiRouter.Group("/access-logs") accessLogRoute.Use(middleware.AdminAuth()) diff --git a/openflare_server/router/api_phase2_test.go b/openflare_server/router/api_phase2_test.go index 3c3f4579..659b1384 100644 --- a/openflare_server/router/api_phase2_test.go +++ b/openflare_server/router/api_phase2_test.go @@ -323,11 +323,49 @@ func TestPhase2AgentLifecycle(t *testing.T) { t.Fatal("expected heartbeat response to include active config summary") } - logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil) - var logs []model.ApplyLog + logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID+"&pageNo=1&pageSize=1", nil) + var logs service.ApplyLogListResult decodeResponseData(t, logsResp, &logs) - if len(logs) != 2 { - t.Fatalf("expected 2 apply logs, got %d", len(logs)) + if logs.Current != 1 || logs.Total != 2 || logs.TotalPage != 2 { + t.Fatalf("unexpected paged apply logs result: %+v", logs) + } + if len(logs.Rows) != 1 { + t.Fatalf("expected 1 apply log row on page 1, got %d", len(logs.Rows)) + } + if logs.Rows[0].Result != service.ApplyResultFailed { + t.Fatalf("expected newest apply log first, got %s", logs.Rows[0].Result) + } + oldApplyLogTime := time.Now().Add(-48 * time.Hour) + if err := model.DB.Model(&model.ApplyLog{}).Where("id = ?", successApplyLog.ID).Update("created_at", oldApplyLogTime).Error; err != nil { + t.Fatalf("failed to backdate apply log: %v", err) + } + cleanupResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/apply-logs/cleanup", map[string]any{ + "retention_days": 1, + }) + var cleanupResult service.ApplyLogCleanupResult + decodeResponseData(t, cleanupResp, &cleanupResult) + if cleanupResult.DeleteAll { + t.Fatal("expected retention cleanup instead of delete-all cleanup") + } + if cleanupResult.RetentionDays != 1 || cleanupResult.DeletedCount != 1 { + t.Fatalf("unexpected cleanup result: %+v", cleanupResult) + } + postCleanupResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil) + decodeResponseData(t, postCleanupResp, &logs) + if logs.Total != 1 || len(logs.Rows) != 1 { + t.Fatalf("expected one apply log after retention cleanup, got %+v", logs) + } + deleteAllResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/apply-logs/cleanup", map[string]any{ + "delete_all": true, + }) + decodeResponseData(t, deleteAllResp, &cleanupResult) + if !cleanupResult.DeleteAll || cleanupResult.DeletedCount != 1 { + t.Fatalf("unexpected delete-all cleanup result: %+v", cleanupResult) + } + emptyLogsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil) + decodeResponseData(t, emptyLogsResp, &logs) + if logs.Total != 0 || len(logs.Rows) != 0 || logs.Current != 1 || logs.TotalPage != 0 { + t.Fatalf("expected empty apply log page after delete-all cleanup, got %+v", logs) } updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/update", map[string]any{ diff --git a/openflare_server/service/agent.go b/openflare_server/service/agent.go index 85edc4f8..3776c02a 100644 --- a/openflare_server/service/agent.go +++ b/openflare_server/service/agent.go @@ -53,6 +53,31 @@ type ApplyLogPayload struct { SupportFileCount int `json:"support_file_count"` } +type ApplyLogListQuery struct { + NodeID string `json:"node_id"` + PageNo int `json:"pageNo"` + PageSize int `json:"pageSize"` +} + +type ApplyLogListResult struct { + Rows []*model.ApplyLog `json:"rows"` + Current int `json:"current"` + Total int `json:"total"` + TotalPage int `json:"totalPage"` +} + +type ApplyLogCleanupInput struct { + DeleteAll bool `json:"delete_all"` + RetentionDays int `json:"retention_days"` +} + +type ApplyLogCleanupResult struct { + DeleteAll bool `json:"delete_all"` + RetentionDays int `json:"retention_days"` + DeletedCount int64 `json:"deleted_count"` + Cutoff *time.Time `json:"cutoff,omitempty"` +} + type AgentConfigResponse struct { Version string `json:"version"` Checksum string `json:"checksum"` @@ -314,8 +339,81 @@ func ListNodeViews() ([]*NodeView, error) { return views, nil } -func ListApplyLogs(nodeID string) ([]*model.ApplyLog, error) { - return model.ListApplyLogs(strings.TrimSpace(nodeID)) +const ( + defaultApplyLogPageSize = 20 + maxApplyLogPageSize = 200 + maxApplyLogRetentionDays = 3650 +) + +func ListApplyLogsPage(input ApplyLogListQuery) (*ApplyLogListResult, error) { + pageNo := normalizeApplyLogPageNo(input.PageNo) + pageSize := normalizeApplyLogPageSize(input.PageSize) + nodeID := strings.TrimSpace(input.NodeID) + rows, err := model.ListApplyLogs(model.ApplyLogQuery{ + NodeID: nodeID, + PageNo: pageNo, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + total, err := model.CountApplyLogs(nodeID) + if err != nil { + return nil, err + } + totalPage := 0 + if total > 0 { + totalPage = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + return &ApplyLogListResult{ + Rows: rows, + Current: pageNo, + Total: int(total), + TotalPage: totalPage, + }, nil +} + +func CleanupApplyLogs(input ApplyLogCleanupInput) (*ApplyLogCleanupResult, error) { + if input.DeleteAll { + deleted, err := model.DeleteAllApplyLogs() + if err != nil { + return nil, err + } + return &ApplyLogCleanupResult{ + DeleteAll: true, + DeletedCount: deleted, + }, nil + } + if input.RetentionDays <= 0 || input.RetentionDays > maxApplyLogRetentionDays { + return nil, errors.New("retention_days 必须在 1 到 3650 之间") + } + cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour) + deleted, err := model.DeleteApplyLogsBefore(cutoff) + if err != nil { + return nil, err + } + return &ApplyLogCleanupResult{ + RetentionDays: input.RetentionDays, + DeletedCount: deleted, + Cutoff: &cutoff, + }, nil +} + +func normalizeApplyLogPageNo(pageNo int) int { + if pageNo <= 0 { + return 1 + } + return pageNo +} + +func normalizeApplyLogPageSize(pageSize int) int { + if pageSize <= 0 { + return defaultApplyLogPageSize + } + if pageSize > maxApplyLogPageSize { + return maxApplyLogPageSize + } + return pageSize } func upsertNode(payload AgentNodePayload) (*model.Node, error) { diff --git a/openflare_server/web/features/apply-logs/api/apply-logs.ts b/openflare_server/web/features/apply-logs/api/apply-logs.ts index ec259436..156740a9 100644 --- a/openflare_server/web/features/apply-logs/api/apply-logs.ts +++ b/openflare_server/web/features/apply-logs/api/apply-logs.ts @@ -1,9 +1,31 @@ import { apiRequest } from '@/lib/api/client'; -import type { ApplyLogItem } from '@/features/apply-logs/types'; +import type { + ApplyLogCleanupPayload, + ApplyLogCleanupResult, + ApplyLogList, + ApplyLogListQuery, +} from '@/features/apply-logs/types'; -export function getApplyLogs(nodeId?: string) { - const normalizedNodeId = nodeId?.trim(); - const query = normalizedNodeId ? `?node_id=${encodeURIComponent(normalizedNodeId)}` : ''; - return apiRequest(`/apply-logs/${query}`); +export function getApplyLogs(query: ApplyLogListQuery = {}) { + const params = new URLSearchParams(); + const normalizedNodeId = query.node_id?.trim(); + if (normalizedNodeId) { + params.set('node_id', normalizedNodeId); + } + if (query.pageNo) { + params.set('pageNo', String(query.pageNo)); + } + if (query.pageSize) { + params.set('pageSize', String(query.pageSize)); + } + const suffix = params.size > 0 ? `?${params.toString()}` : ''; + return apiRequest(`/apply-logs/${suffix}`); +} + +export function cleanupApplyLogs(payload: ApplyLogCleanupPayload) { + return apiRequest('/apply-logs/cleanup', { + method: 'POST', + body: JSON.stringify(payload), + }); } diff --git a/openflare_server/web/features/apply-logs/components/apply-logs-page.tsx b/openflare_server/web/features/apply-logs/components/apply-logs-page.tsx index e1f6f170..491510a6 100644 --- a/openflare_server/web/features/apply-logs/components/apply-logs-page.tsx +++ b/openflare_server/web/features/apply-logs/components/apply-logs-page.tsx @@ -1,349 +1,508 @@ -'use client'; - -import Link from 'next/link'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; - -import { EmptyState } from '@/components/feedback/empty-state'; -import { ErrorState } from '@/components/feedback/error-state'; -import { InlineMessage } from '@/components/feedback/inline-message'; -import { LoadingState } from '@/components/feedback/loading-state'; -import { PageHeader } from '@/components/layout/page-header'; -import { AppCard } from '@/components/ui/app-card'; -import { StatusBadge } from '@/components/ui/status-badge'; -import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; -import type { ApplyLogItem } from '@/features/apply-logs/types'; -import { - PrimaryButton, - ResourceInput, - SecondaryButton, -} from '@/features/shared/components/resource-primitives'; -import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; - -const applyLogsQueryKey = (nodeId: string) => ['apply-logs', nodeId] as const; - -function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : '请求失败,请稍后重试。'; -} - -function getResultMeta(result: string) { - if (result === 'success') { - return { label: '成功', variant: 'success' as const }; - } - - if (result === 'warning') { - return { label: '警告', variant: 'warning' as const }; - } - - return { label: '失败', variant: 'danger' as const }; -} - -function buildSummary(logs: ApplyLogItem[]) { - const nodeIds = new Set(logs.map((item) => item.node_id)); - - return [ - { label: '记录总数', value: logs.length }, - { - label: '成功', - value: logs.filter((item) => item.result === 'success').length, - }, - { - label: '失败', - value: logs.filter((item) => item.result !== 'success').length, - }, - { label: '节点数', value: nodeIds.size }, - ]; -} - -function truncateHash(value: string) { - if (!value) { - return '—'; - } - - return value.length > 12 ? `${value.slice(0, 12)}...` : value; -} - -export function ApplyLogsPage() { - const queryClient = useQueryClient(); - const [nodeFilterInput, setNodeFilterInput] = useState(''); - const [nodeFilter, setNodeFilter] = useState(''); - const [selectedLogId, setSelectedLogId] = useState(null); - const [feedback, setFeedback] = useState(null); - - const logsQuery = useQuery({ - queryKey: applyLogsQueryKey(nodeFilter), - queryFn: () => getApplyLogs(nodeFilter), - }); - - const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]); - const summary = useMemo(() => buildSummary(logs), [logs]); - - useEffect(() => { - if (logs.length === 0) { - setSelectedLogId(null); - return; - } - - if (!logs.some((item) => item.id === selectedLogId)) { - setSelectedLogId(logs[0].id); - } - }, [logs, selectedLogId]); - - const selectedLog = logs.find((item) => item.id === selectedLogId) ?? null; - - const handleSearch = () => { - setFeedback(null); - setNodeFilter(nodeFilterInput.trim()); - }; - - const handleReset = () => { - setFeedback(null); - setNodeFilter(''); - setNodeFilterInput(''); - }; - - return ( -
- - 返回节点页 - - } - /> - - {feedback ? : null} - - -
- {summary.map((item) => ( -
-

- {item.label} -

-

- {item.value} -

-
- ))} -
-
- -
- - void queryClient.invalidateQueries({ - queryKey: applyLogsQueryKey(nodeFilter), - }) - } - > - 刷新 - - } - > -
-
- setNodeFilterInput(event.target.value)} - placeholder="输入 node_id 过滤应用记录" - className="lg:max-w-md" - /> -
- - 筛选 - - - 清空 - -
-
- - {logsQuery.isLoading ? ( - - ) : logsQuery.isError ? ( - - ) : logs.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - {logs.map((log) => { - const resultMeta = getResultMeta(log.result); - return ( - - - - - - - - - ); - })} - -
Node ID版本结果Checksum时间详情
- {log.node_id} - - {log.version} - - - - {truncateHash(log.checksum)} - -
-

{formatDateTime(log.created_at)}

-

- {formatRelativeTime(log.created_at)} -

-
-
- { - setSelectedLogId(log.id); - setFeedback( - `已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`, - ); - }} - className="px-3 py-2 text-xs" - > - 查看详情 - -
-
- )} -
-
- - - {selectedLog ? ( -
-
- - - -
- -
-
-

- 创建时间 -

-

- {formatDateTime(selectedLog.created_at)} -

-
-
-

- 相对时间 -

-

- {formatRelativeTime(selectedLog.created_at)} -

-
-
-

- 目标 Checksum -

-

- {selectedLog.checksum || '无'} -

-
-
-

- 支持文件数 -

-

- {selectedLog.support_file_count} -

-
-
- -
-
-

- 主配置摘要 -

-

- {selectedLog.main_config_checksum || '无'} -

-
-
-

- 路由配置摘要 -

-

- {selectedLog.route_config_checksum || '无'} -

-
-
- -
-

- 应用信息 -

-

- {selectedLog.message || '无附加信息'} -

-
-
- ) : ( - - )} -
-
-
- ); -} +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { AppModal } from '@/components/ui/app-modal'; +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { + cleanupApplyLogs, + getApplyLogs, +} from '@/features/apply-logs/api/apply-logs'; +import type { + ApplyLogCleanupPayload, + ApplyLogItem, +} from '@/features/apply-logs/types'; +import { + PrimaryButton, + ResourceField, + ResourceInput, + ResourceSelect, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; + +const applyLogsQueryKey = ( + nodeId: string, + pageNo: number, + pageSize: number, +) => ['apply-logs', nodeId, pageNo, pageSize] as const; + +const pageSizeOptions = [20, 50, 100]; +const emptyApplyLogRows: ApplyLogItem[] = []; + +type FeedbackState = { + tone: 'info' | 'success' | 'danger'; + message: string; +}; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function getResultMeta(result: string) { + if (result === 'success') { + return { label: '成功', variant: 'success' as const }; + } + if (result === 'warning') { + return { label: '警告', variant: 'warning' as const }; + } + return { label: '失败', variant: 'danger' as const }; +} + +function truncateHash(value: string) { + if (!value) { + return '—'; + } + return value.length > 12 ? `${value.slice(0, 12)}...` : value; +} + +function buildSummary(rows: ApplyLogItem[], total: number, current: number, totalPage: number) { + const nodeIds = new Set(rows.map((item) => item.node_id)); + return [ + { label: '总记录数', value: total }, + { label: '当前页', value: current }, + { label: '总页数', value: totalPage }, + { label: '当前页节点数', value: nodeIds.size }, + ]; +} + +export function ApplyLogsPage() { + const queryClient = useQueryClient(); + const [nodeFilterInput, setNodeFilterInput] = useState(''); + const [nodeFilter, setNodeFilter] = useState(''); + const [pageNo, setPageNo] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [selectedLog, setSelectedLog] = useState(null); + const [isCleanupModalOpen, setCleanupModalOpen] = useState(false); + const [cleanupMode, setCleanupMode] = useState<'all' | 'custom'>('custom'); + const [customRetentionDays, setCustomRetentionDays] = useState('30'); + const [feedback, setFeedback] = useState(null); + + const logsQuery = useQuery({ + queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize), + queryFn: () => + getApplyLogs({ + node_id: nodeFilter || undefined, + pageNo, + pageSize, + }), + placeholderData: (previous) => previous, + }); + + const cleanupMutation = useMutation({ + mutationFn: (payload: ApplyLogCleanupPayload) => cleanupApplyLogs(payload), + onSuccess: async (result) => { + setCleanupModalOpen(false); + setPageNo(1); + setFeedback({ + tone: 'success', + message: result.delete_all + ? `已删除全部应用日志,共 ${result.deleted_count} 条。` + : `已清理保留期之外的应用日志,共 ${result.deleted_count} 条。`, + }); + await queryClient.invalidateQueries({ queryKey: ['apply-logs'] }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const rows = logsQuery.data?.rows ?? emptyApplyLogRows; + const current = logsQuery.data?.current ?? pageNo; + const total = logsQuery.data?.total ?? 0; + const totalPage = logsQuery.data?.totalPage ?? 0; + const summary = useMemo( + () => buildSummary(rows, total, current, totalPage), + [rows, total, current, totalPage], + ); + + useEffect(() => { + if (rows.length === 0 && selectedLog) { + setSelectedLog(null); + return; + } + if (selectedLog && !rows.some((item) => item.id === selectedLog.id)) { + setSelectedLog(null); + } + }, [rows, selectedLog]); + + const handleSearch = () => { + setFeedback(null); + setPageNo(1); + setNodeFilter(nodeFilterInput.trim()); + }; + + const handleReset = () => { + setFeedback(null); + setNodeFilter(''); + setNodeFilterInput(''); + setPageNo(1); + }; + + const handleRefresh = async () => { + setFeedback(null); + await queryClient.invalidateQueries({ + queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize), + }); + }; + + const handleCleanupConfirm = () => { + const payload: ApplyLogCleanupPayload = + cleanupMode === 'all' + ? { delete_all: true } + : { + retention_days: Number.parseInt(customRetentionDays, 10), + }; + cleanupMutation.mutate(payload); + }; + + return ( +
+ + + 刷新 + + { + setFeedback(null); + setCleanupModalOpen(true); + }} + > + 删除日志 + + + 返回节点 + +
+ } + /> + + {feedback ? ( + + ) : null} + + +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+ + +
+
+
+ + setNodeFilterInput(event.target.value)} + placeholder="输入 node_id 过滤应用日志" + /> + + + { + setPageSize(Number.parseInt(event.target.value, 10)); + setPageNo(1); + }} + > + {pageSizeOptions.map((option) => ( + + ))} + + +
+
+ + 筛选 + + + 清空 + +
+
+ + {logsQuery.isLoading ? ( + + ) : logsQuery.isError ? ( + + ) : rows.length === 0 ? ( + + ) : ( + <> +
+ + + + + + + + + + + + + + {rows.map((log) => { + const resultMeta = getResultMeta(log.result); + return ( + + + + + + + + + + ); + })} + +
Node ID版本结果Checksum时间消息操作
+ {log.node_id} + + {log.version} + + + + {truncateHash(log.checksum)} + +
+

{formatDateTime(log.created_at)}

+

+ {formatRelativeTime(log.created_at)} +

+
+
+
+ {log.message || '—'} +
+
+ setSelectedLog(log)} + > + 查看详情 + +
+
+ +
+

+ 第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。 +

+
+ setPageNo((previous) => Math.max(1, previous - 1))} + > + 上一页 + + = totalPage} + onClick={() => + setPageNo((previous) => + totalPage > 0 ? Math.min(totalPage, previous + 1) : previous, + ) + } + > + 下一页 + +
+
+ + )} +
+
+ + setSelectedLog(null)} + > + {selectedLog ? ( +
+
+ + + +
+ +
+ + + + + + +
+ +
+

+ 消息 +

+
+                {selectedLog.message || '—'}
+              
+
+
+ ) : null} +
+ + setCleanupModalOpen(false)} + footer={ +
+ setCleanupModalOpen(false)}> + 取消 + + + {cleanupMutation.isPending ? '处理中...' : '确认删除'} + +
+ } + > +
+
+ + +
+ + {cleanupMode === 'custom' ? ( + + setCustomRetentionDays(event.target.value)} + type="number" + min={1} + max={3650} + placeholder="输入保留天数" + /> + + ) : null} + + {cleanupMutation.isError ? ( + + ) : null} +
+
+ + ); +} + +function MetricCard({ + label, + value, + breakAll = false, +}: { + label: string; + value: string; + breakAll?: boolean; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} diff --git a/openflare_server/web/features/apply-logs/types.ts b/openflare_server/web/features/apply-logs/types.ts index 82049a3d..15a3e9c9 100644 --- a/openflare_server/web/features/apply-logs/types.ts +++ b/openflare_server/web/features/apply-logs/types.ts @@ -10,3 +10,28 @@ export interface ApplyLogItem { support_file_count: number; created_at: string; } + +export interface ApplyLogList { + rows: ApplyLogItem[]; + current: number; + total: number; + totalPage: number; +} + +export interface ApplyLogListQuery { + node_id?: string; + pageNo?: number; + pageSize?: number; +} + +export interface ApplyLogCleanupPayload { + delete_all?: boolean; + retention_days?: number; +} + +export interface ApplyLogCleanupResult { + delete_all: boolean; + retention_days: number; + deleted_count: number; + cutoff?: string; +} diff --git a/openflare_server/web/features/nodes/components/node-detail-page.tsx b/openflare_server/web/features/nodes/components/node-detail-page.tsx index 56198b95..dc8b4c6e 100644 --- a/openflare_server/web/features/nodes/components/node-detail-page.tsx +++ b/openflare_server/web/features/nodes/components/node-detail-page.tsx @@ -276,8 +276,13 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { }, [nodeId, nodesQuery.data]); const applyLogsQuery = useQuery({ - queryKey: ['apply-logs', node?.node_id ?? ''], - queryFn: () => getApplyLogs(node?.node_id), + queryKey: ['apply-logs', node?.node_id ?? '', 1, 10], + queryFn: () => + getApplyLogs({ + node_id: node?.node_id, + pageNo: 1, + pageSize: 10, + }), enabled: Boolean(node?.node_id), refetchInterval: 5000, }); @@ -525,7 +530,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { selectedReleaseChannel === 'preview' ? previewAgentReleaseQuery.isFetching : stableAgentReleaseQuery.isFetching; - const applyLogs = applyLogsQuery.data ?? []; + const applyLogs = applyLogsQuery.data?.rows ?? []; const dominantStatusCode = statusCodeDistribution[0] ?? null; const dominantDomain = topDomains[0] ?? null; const topSourceCountry = @@ -1623,7 +1628,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { - {applyLogs.slice(0, 10).map((log) => ( + {applyLogs.map((log) => ( {log.version}