diff --git a/openflare_server/controller/access_log.go b/openflare_server/controller/access_log.go index f2d10822..5a784fed 100644 --- a/openflare_server/controller/access_log.go +++ b/openflare_server/controller/access_log.go @@ -59,6 +59,43 @@ func GetFoldedAccessLogs(c *gin.Context) { respondSuccess(c, logs) } +// GetFoldedAccessLogIPs godoc +// @Summary List folded access log IP summaries +// @Tags AccessLogs +// @Produce json +// @Security BearerAuth +// @Param node_id query string false "Node ID" +// @Param remote_addr query string false "Remote address" +// @Param host query string false "Host" +// @Param path query string false "Path" +// @Param bucket_started_at query string true "Bucket started at" +// @Param fold_minutes query int true "Fold minutes" +// @Param p query int false "Page index" +// @Param page_size query int false "Page size" +// @Param sort_by query string false "Sort by" +// @Param sort_order query string false "Sort order" +// @Success 200 {object} map[string]interface{} +// @Router /api/access-logs/folds/ip-summary [get] +func GetFoldedAccessLogIPs(c *gin.Context) { + result, err := service.ListFoldedAccessLogIPs(service.FoldedAccessLogIPQuery{ + NodeID: c.Query("node_id"), + RemoteAddr: c.Query("remote_addr"), + Host: c.Query("host"), + Path: c.Query("path"), + BucketStartedAt: c.Query("bucket_started_at"), + FoldMinutes: readQueryInt(c, "fold_minutes"), + Page: readQueryInt(c, "p"), + PageSize: readQueryInt(c, "page_size"), + SortBy: c.Query("sort_by"), + SortOrder: c.Query("sort_order"), + }) + if err != nil { + respondFailure(c, err.Error()) + return + } + respondSuccess(c, result) +} + // GetAccessLogIPSummaries godoc // @Summary List access log IP summaries // @Tags AccessLogs diff --git a/openflare_server/model/node_access_log.go b/openflare_server/model/node_access_log.go index 19971154..c1ece99e 100644 --- a/openflare_server/model/node_access_log.go +++ b/openflare_server/model/node_access_log.go @@ -32,6 +32,7 @@ type NodeAccessLogQuery struct { Host string Path string Since time.Time + Until time.Time Page int PageSize int SortBy string @@ -61,6 +62,28 @@ type NodeAccessLogBucketRow struct { ServerErrorCount int64 `json:"server_error_count"` } +type NodeAccessLogBucketIPQuery struct { + NodeID string + RemoteAddr string + Host string + Path string + BucketStartedAt time.Time + FoldMinutes int + Page int + PageSize int + SortBy string + SortOrder string +} + +type NodeAccessLogBucketIPRow struct { + RemoteAddr string `json:"remote_addr"` + RequestCount int64 `json:"request_count"` + SuccessCount int64 `json:"success_count"` + ClientErrorCount int64 `json:"client_error_count"` + ServerErrorCount int64 `json:"server_error_count"` + LastSeenEpoch int64 `json:"last_seen_epoch"` +} + type NodeAccessLogIPSummaryQuery struct { NodeID string RemoteAddr string @@ -184,6 +207,26 @@ func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err return int64(len(rows)), nil } +func ListNodeAccessLogBucketIPs(query NodeAccessLogBucketIPQuery) (items []*NodeAccessLogBucketIPRow, err error) { + rows, err := buildNodeAccessLogBucketIPRows(query) + if err != nil { + return nil, err + } + start, end := paginateBounds(len(rows), query.Page, query.PageSize) + if start >= len(rows) { + return []*NodeAccessLogBucketIPRow{}, nil + } + return rows[start:end], nil +} + +func CountNodeAccessLogBucketIPs(query NodeAccessLogBucketIPQuery) (total int64, err error) { + rows, err := buildNodeAccessLogBucketIPRows(query) + if err != nil { + return 0, err + } + return int64(len(rows)), nil +} + func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) { rows, err := buildNodeAccessLogIPSummaryRows(query, recentSince) if err != nil { @@ -309,6 +352,9 @@ func applyNodeAccessLogFilters(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB { if !query.Since.IsZero() { db = db.Where("logged_at >= ?", query.Since) } + if !query.Until.IsZero() { + db = db.Where("logged_at < ?", query.Until) + } return db } @@ -392,6 +438,75 @@ func buildNodeAccessLogBucketRows(query NodeAccessLogBucketQuery) ([]*NodeAccess return rows, nil } +func buildNodeAccessLogBucketIPRows(query NodeAccessLogBucketIPQuery) ([]*NodeAccessLogBucketIPRow, error) { + if query.BucketStartedAt.IsZero() { + return []*NodeAccessLogBucketIPRow{}, nil + } + foldMinutes := query.FoldMinutes + if foldMinutes <= 0 { + foldMinutes = 3 + } + bucketStartedAt := query.BucketStartedAt.UTC() + logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{ + NodeID: query.NodeID, + RemoteAddr: query.RemoteAddr, + Host: query.Host, + Path: query.Path, + Since: bucketStartedAt, + Until: bucketStartedAt.Add(time.Duration(foldMinutes) * time.Minute), + }) + if err != nil { + return nil, err + } + type accumulator struct { + requestCount int64 + successCount int64 + clientErrorCount int64 + serverErrorCount int64 + lastSeenAt time.Time + } + accumulators := make(map[string]*accumulator) + for _, item := range logs { + if item == nil { + continue + } + remoteAddr := strings.TrimSpace(item.RemoteAddr) + if remoteAddr == "" { + continue + } + acc := accumulators[remoteAddr] + if acc == nil { + acc = &accumulator{} + accumulators[remoteAddr] = acc + } + acc.requestCount++ + switch { + case item.StatusCode < 400: + acc.successCount++ + case item.StatusCode < 500: + acc.clientErrorCount++ + default: + acc.serverErrorCount++ + } + if item.LoggedAt.After(acc.lastSeenAt) { + acc.lastSeenAt = item.LoggedAt + } + } + rows := make([]*NodeAccessLogBucketIPRow, 0, len(accumulators)) + for remoteAddr, acc := range accumulators { + rows = append(rows, &NodeAccessLogBucketIPRow{ + RemoteAddr: remoteAddr, + RequestCount: acc.requestCount, + SuccessCount: acc.successCount, + ClientErrorCount: acc.clientErrorCount, + ServerErrorCount: acc.serverErrorCount, + LastSeenEpoch: acc.lastSeenAt.Unix(), + }) + } + sortNodeAccessLogBucketIPRows(rows, query.SortBy, query.SortOrder) + return rows, nil +} + func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSince time.Time) ([]*NodeAccessLogIPSummaryRow, error) { logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{ NodeID: query.NodeID, @@ -442,6 +557,36 @@ func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSi return rows, nil } +func sortNodeAccessLogBucketIPRows(items []*NodeAccessLogBucketIPRow, sortBy string, sortOrder string) { + desc := normalizeSortOrder(sortOrder) != "asc" + sort.Slice(items, func(i int, j int) bool { + left := items[i] + right := items[j] + if left == nil || right == nil { + return left != nil + } + var compare int + switch strings.TrimSpace(sortBy) { + case "last_seen_at": + compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch) + case "remote_addr": + compare = strings.Compare(left.RemoteAddr, right.RemoteAddr) + default: + compare = compareInt64(left.RequestCount, right.RequestCount) + } + if compare == 0 { + compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch) + } + if compare == 0 { + compare = strings.Compare(left.RemoteAddr, right.RemoteAddr) + } + if desc { + return compare > 0 + } + return compare < 0 + }) +} + func sortNodeAccessLogs(items []*NodeAccessLog, sortBy string, sortOrder string) { desc := normalizeSortOrder(sortOrder) != "asc" sort.Slice(items, func(i int, j int) bool { diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 1bb405c5..03b407c0 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -184,6 +184,7 @@ func SetApiRouter(router *gin.Engine) { { accessLogRoute.GET("/", controller.GetAccessLogs) accessLogRoute.GET("/folds", controller.GetFoldedAccessLogs) + accessLogRoute.GET("/folds/ip-summary", controller.GetFoldedAccessLogIPs) accessLogRoute.GET("/ip-summary", controller.GetAccessLogIPSummaries) accessLogRoute.GET("/ip-summary/trend", controller.GetAccessLogIPTrend) accessLogRoute.POST("/cleanup", controller.CleanupAccessLogs) diff --git a/openflare_server/service/access_log.go b/openflare_server/service/access_log.go index 45808ab2..c169ac0a 100644 --- a/openflare_server/service/access_log.go +++ b/openflare_server/service/access_log.go @@ -73,6 +73,40 @@ type FoldedAccessLogList struct { FoldMinutes int `json:"fold_minutes"` } +type FoldedAccessLogIPQuery struct { + NodeID string `json:"node_id"` + RemoteAddr string `json:"remote_addr"` + Host string `json:"host"` + Path string `json:"path"` + BucketStartedAt string `json:"bucket_started_at"` + FoldMinutes int `json:"fold_minutes"` + Page int `json:"page"` + PageSize int `json:"page_size"` + SortBy string `json:"sort_by"` + SortOrder string `json:"sort_order"` +} + +type FoldedAccessLogIPView struct { + RemoteAddr string `json:"remote_addr"` + RequestCount int64 `json:"request_count"` + SuccessCount int64 `json:"success_count"` + ClientErrorCount int64 `json:"client_error_count"` + ServerErrorCount int64 `json:"server_error_count"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +type FoldedAccessLogIPList struct { + Items []FoldedAccessLogIPView `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + HasMore bool `json:"has_more"` + TotalIP int64 `json:"total_ip"` + BucketStartedAt time.Time `json:"bucket_started_at"` + FoldMinutes int `json:"fold_minutes"` + SortBy string `json:"sort_by"` + SortOrder string `json:"sort_order"` +} + type AccessLogIPSummaryQuery struct { NodeID string `json:"node_id"` RemoteAddr string `json:"remote_addr"` @@ -187,7 +221,7 @@ func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) { Since: modelQuery.Since, Page: normalized.Page, PageSize: normalized.PageSize, - SortBy: normalizeFoldSortBy(normalized.SortBy), + SortBy: normalizeFoldSortBy(input.SortBy), SortOrder: normalized.SortOrder, FoldMinutes: foldMinutes, } @@ -230,6 +264,58 @@ func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) { }, nil } +func ListFoldedAccessLogIPs(input FoldedAccessLogIPQuery) (*FoldedAccessLogIPList, error) { + normalized, bucketStartedAt, err := normalizeFoldedAccessLogIPQuery(input) + if err != nil { + return nil, err + } + modelQuery := model.NodeAccessLogBucketIPQuery{ + NodeID: normalized.NodeID, + RemoteAddr: normalized.RemoteAddr, + Host: normalized.Host, + Path: normalized.Path, + BucketStartedAt: bucketStartedAt, + FoldMinutes: normalized.FoldMinutes, + Page: normalized.Page, + PageSize: normalized.PageSize, + SortBy: normalized.SortBy, + SortOrder: normalized.SortOrder, + } + items, err := model.ListNodeAccessLogBucketIPs(modelQuery) + if err != nil { + return nil, err + } + totalIP, err := model.CountNodeAccessLogBucketIPs(modelQuery) + if err != nil { + return nil, err + } + views := make([]FoldedAccessLogIPView, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + views = append(views, FoldedAccessLogIPView{ + RemoteAddr: item.RemoteAddr, + RequestCount: item.RequestCount, + SuccessCount: item.SuccessCount, + ClientErrorCount: item.ClientErrorCount, + ServerErrorCount: item.ServerErrorCount, + LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(), + }) + } + return &FoldedAccessLogIPList{ + Items: views, + Page: normalized.Page, + PageSize: normalized.PageSize, + HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP, + TotalIP: totalIP, + BucketStartedAt: bucketStartedAt, + FoldMinutes: normalized.FoldMinutes, + SortBy: normalized.SortBy, + SortOrder: normalized.SortOrder, + }, nil +} + func ListAccessLogIPSummaries(input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) { normalized := normalizeAccessLogIPSummaryQuery(input) since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow) @@ -398,6 +484,35 @@ func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) AccessLogIP } } +func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccessLogIPQuery, time.Time, error) { + foldMinutes, err := normalizeFoldMinutes(input.FoldMinutes) + if err != nil { + return FoldedAccessLogIPQuery{}, time.Time{}, err + } + bucketStartedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(input.BucketStartedAt)) + if err != nil { + return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at 必须为 RFC3339 时间") + } + normalizedSortBy := strings.TrimSpace(input.SortBy) + switch normalizedSortBy { + case "last_seen_at", "remote_addr": + default: + normalizedSortBy = "request_count" + } + return FoldedAccessLogIPQuery{ + NodeID: strings.TrimSpace(input.NodeID), + RemoteAddr: strings.TrimSpace(input.RemoteAddr), + Host: strings.TrimSpace(input.Host), + Path: strings.TrimSpace(input.Path), + BucketStartedAt: strings.TrimSpace(input.BucketStartedAt), + FoldMinutes: foldMinutes, + Page: normalizeAccessLogPage(input.Page), + PageSize: normalizeAccessLogPageSize(input.PageSize), + SortBy: normalizedSortBy, + SortOrder: normalizeAccessLogSortOrder(input.SortOrder), + }, bucketStartedAt.UTC(), nil +} + func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) { remoteAddr := strings.TrimSpace(input.RemoteAddr) if remoteAddr == "" { diff --git a/openflare_server/service/access_log_test.go b/openflare_server/service/access_log_test.go index b9000415..0d95a43c 100644 --- a/openflare_server/service/access_log_test.go +++ b/openflare_server/service/access_log_test.go @@ -179,6 +179,8 @@ func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) { NodeID: "node-folded", Page: 0, PageSize: 10, + SortBy: "request_count", + SortOrder: "desc", FoldMinutes: 5, }) if err != nil { @@ -193,6 +195,28 @@ func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) { if folded.Items[0].RequestCount+folded.Items[1].RequestCount != 3 { t.Fatalf("unexpected folded request count sum: %+v", folded.Items) } + if folded.Items[0].RequestCount != 2 { + t.Fatalf("expected folded buckets to sort by request_count desc, got %+v", folded.Items) + } + + bucketIPs, err := ListFoldedAccessLogIPs(FoldedAccessLogIPQuery{ + NodeID: "node-folded", + BucketStartedAt: folded.Items[0].BucketStartedAt.Format(time.RFC3339), + FoldMinutes: 5, + Page: 0, + PageSize: 10, + SortBy: "request_count", + SortOrder: "desc", + }) + if err != nil { + t.Fatalf("ListFoldedAccessLogIPs failed: %v", err) + } + if bucketIPs.TotalIP != 1 || len(bucketIPs.Items) != 1 { + t.Fatalf("expected one folded bucket IP row, got %+v", bucketIPs) + } + if bucketIPs.Items[0].RemoteAddr != "203.0.113.1" || bucketIPs.Items[0].RequestCount != 2 { + t.Fatalf("unexpected top folded bucket IP row: %+v", bucketIPs.Items[0]) + } ipSummaries, err := ListAccessLogIPSummaries(AccessLogIPSummaryQuery{ NodeID: "node-folded", diff --git a/openflare_server/web/features/access-logs/api/access-logs.ts b/openflare_server/web/features/access-logs/api/access-logs.ts index a3b0211b..d2dc2284 100644 --- a/openflare_server/web/features/access-logs/api/access-logs.ts +++ b/openflare_server/web/features/access-logs/api/access-logs.ts @@ -9,13 +9,17 @@ import type { AccessLogIPTrend, AccessLogIPTrendFilters, AccessLogList, + FoldedAccessLogIPFilters, + FoldedAccessLogIPList, FoldedAccessLogFilters, FoldedAccessLogList, } from '@/features/access-logs/types'; function buildSearchParams(filters: object) { const searchParams = new URLSearchParams(); - Object.entries(filters as Record).forEach(([key, value]) => { + Object.entries( + filters as Record, + ).forEach(([key, value]) => { if (value === undefined || value === null || value === '') { return; } @@ -31,7 +35,16 @@ export function getAccessLogs(filters: AccessLogFilters) { export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) { const query = buildSearchParams(filters); - return apiRequest(`/access-logs/folds${query ? `?${query}` : ''}`); + return apiRequest( + `/access-logs/folds${query ? `?${query}` : ''}`, + ); +} + +export function getFoldedAccessLogIPs(filters: FoldedAccessLogIPFilters) { + const query = buildSearchParams(filters); + return apiRequest( + `/access-logs/folds/ip-summary${query ? `?${query}` : ''}`, + ); } export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) { diff --git a/openflare_server/web/features/access-logs/components/access-logs-page.tsx b/openflare_server/web/features/access-logs/components/access-logs-page.tsx index fdd0165e..30838c44 100644 --- a/openflare_server/web/features/access-logs/components/access-logs-page.tsx +++ b/openflare_server/web/features/access-logs/components/access-logs-page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { Fragment, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { TrendChart } from '@/components/data/trend-chart'; @@ -16,6 +16,7 @@ import { getAccessLogIPSummaries, getAccessLogIPTrend, getAccessLogs, + getFoldedAccessLogIPs, getFoldedAccessLogs, } from '@/features/access-logs/api/access-logs'; import type { @@ -23,6 +24,7 @@ import type { AccessLogIPSummaryItem, AccessLogIPSummaryList, AccessLogList, + FoldedAccessLogIPList, FoldedAccessLogList, } from '@/features/access-logs/types'; import { @@ -94,7 +96,10 @@ function buildSummary(totalRecord = 0, totalIP = 0, activeTab: ActiveTab) { return [ { label: '访问记录', value: formatCompactNumber(totalRecord) }, { label: '来源 IP', value: formatCompactNumber(totalIP) }, - { label: '当前视图', value: activeTab === 'detail' ? '明细日志' : 'IP 维度' }, + { + label: '当前视图', + value: activeTab === 'detail' ? '明细日志' : 'IP 维度', + }, ]; } @@ -112,6 +117,36 @@ function buildTrendLabels(points: Array<{ bucket_started_at: string }>) { }); } +function isFoldedAccessLogList( + value: AccessLogList | FoldedAccessLogList | undefined, +): value is FoldedAccessLogList { + if (!value || !Array.isArray(value.items)) { + return false; + } + return value.items.every((item) => { + const candidate = item as Partial; + return ( + typeof candidate.bucket_started_at === 'string' && + typeof candidate.request_count === 'number' + ); + }); +} + +function isAccessLogList( + value: AccessLogList | FoldedAccessLogList | undefined, +): value is AccessLogList { + if (!value || !Array.isArray(value.items)) { + return false; + } + return value.items.every((item) => { + const candidate = item as Partial; + return ( + typeof candidate.logged_at === 'string' && + typeof candidate.status_code === 'number' + ); + }); +} + export function AccessLogsPage() { const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('detail'); @@ -134,7 +169,13 @@ export function AccessLogsPage() { const [detailSort, setDetailSort] = useState('logged_at:desc'); const [foldedSort, setFoldedSort] = useState('bucket_started_at:desc'); const [ipSort, setIPSort] = useState('total_requests:desc'); - const [selectedIP, setSelectedIP] = useState(null); + const [expandedBucket, setExpandedBucket] = useState(null); + const [bucketIPPages, setBucketIPPages] = useState>( + {}, + ); + const [selectedIP, setSelectedIP] = useState( + null, + ); const [cleanupDays, setCleanupDays] = useState('7'); const [customCleanupDays, setCustomCleanupDays] = useState('14'); const [isCleanupModalOpen, setCleanupModalOpen] = useState(false); @@ -179,8 +220,14 @@ export function AccessLogsPage() { sort_order: detailSortState.sortOrder, }); }, - placeholderData: (previousData: AccessLogList | FoldedAccessLogList | undefined) => - previousData, + placeholderData: ( + previousData: AccessLogList | FoldedAccessLogList | undefined, + ) => { + if (foldMinutes > 0) { + return isFoldedAccessLogList(previousData) ? previousData : undefined; + } + return isAccessLogList(previousData) ? previousData : undefined; + }, }); const ipSummaryQuery = useQuery({ @@ -195,11 +242,18 @@ export function AccessLogsPage() { sort_by: ipSortState.sortBy, sort_order: ipSortState.sortOrder, }), - placeholderData: (previousData: AccessLogIPSummaryList | undefined) => previousData, + placeholderData: (previousData: AccessLogIPSummaryList | undefined) => + previousData, }); const ipTrendQuery = useQuery({ - queryKey: ['access-logs', 'ip-trend', selectedIP?.remote_addr, filters.nodeId, filters.host], + queryKey: [ + 'access-logs', + 'ip-trend', + selectedIP?.remote_addr, + filters.nodeId, + filters.host, + ], queryFn: () => getAccessLogIPTrend({ node_id: filters.nodeId || undefined, @@ -212,18 +266,26 @@ export function AccessLogsPage() { }); const cleanupMutation = useMutation({ - mutationFn: (payload: AccessLogCleanupPayload) => cleanupAccessLogs(payload), + mutationFn: (payload: AccessLogCleanupPayload) => + cleanupAccessLogs(payload), onSuccess: async () => { setCleanupModalOpen(false); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['access-logs', 'detail'] }), - queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-summary'] }), - queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-trend'] }), + queryClient.invalidateQueries({ + queryKey: ['access-logs', 'ip-summary'], + }), + queryClient.invalidateQueries({ + queryKey: ['access-logs', 'ip-trend'], + }), ]); }, }); - const detailSummaryData = detailQuery.data as AccessLogList | FoldedAccessLogList | undefined; + const detailSummaryData = detailQuery.data as + | AccessLogList + | FoldedAccessLogList + | undefined; const summary = useMemo( () => buildSummary( @@ -252,6 +314,8 @@ export function AccessLogsPage() { }); setDetailPage(0); setIPPage(0); + setExpandedBucket(null); + setBucketIPPages({}); }; const handleReset = () => { @@ -261,6 +325,8 @@ export function AccessLogsPage() { setDetailPage(0); setIPPage(0); setSelectedIP(null); + setExpandedBucket(null); + setBucketIPPages({}); }; const handleCleanupConfirm = () => { @@ -277,13 +343,19 @@ export function AccessLogsPage() { title="日志" description="升级后的日志中心支持按节点、IP、域名与路径检索,支持时间折叠、IP 维度聚合与按保留天数清理旧日志。" action={ - setCleanupModalOpen(true)}> + setCleanupModalOpen(true)} + > 清理日志 } /> - +
{summary.map((item) => (
{[ - { key: 'detail', label: '明细日志', description: '按请求明细查看与折叠聚合' }, - { key: 'ip', label: 'IP 维度', description: '查看来源 IP 汇总与趋势' }, + { + key: 'detail', + label: '明细日志', + description: '按请求明细查看与折叠聚合', + }, + { + key: 'ip', + label: 'IP 维度', + description: '查看来源 IP 汇总与趋势', + }, ].map((tab) => (