[优化] 添加获取折叠访问日志 IP 概要的 API 和前端支持

This commit is contained in:
ryan
2026-05-30 10:43:55 +08:00
parent 5d7a4469ea
commit 290ddd7b51
8 changed files with 786 additions and 65 deletions
+37
View File
@@ -59,6 +59,43 @@ func GetFoldedAccessLogs(c *gin.Context) {
respondSuccess(c, logs) 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 // GetAccessLogIPSummaries godoc
// @Summary List access log IP summaries // @Summary List access log IP summaries
// @Tags AccessLogs // @Tags AccessLogs
+145
View File
@@ -32,6 +32,7 @@ type NodeAccessLogQuery struct {
Host string Host string
Path string Path string
Since time.Time Since time.Time
Until time.Time
Page int Page int
PageSize int PageSize int
SortBy string SortBy string
@@ -61,6 +62,28 @@ type NodeAccessLogBucketRow struct {
ServerErrorCount int64 `json:"server_error_count"` 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 { type NodeAccessLogIPSummaryQuery struct {
NodeID string NodeID string
RemoteAddr string RemoteAddr string
@@ -184,6 +207,26 @@ func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err
return int64(len(rows)), nil 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) { func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) {
rows, err := buildNodeAccessLogIPSummaryRows(query, recentSince) rows, err := buildNodeAccessLogIPSummaryRows(query, recentSince)
if err != nil { if err != nil {
@@ -309,6 +352,9 @@ func applyNodeAccessLogFilters(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
if !query.Since.IsZero() { if !query.Since.IsZero() {
db = db.Where("logged_at >= ?", query.Since) db = db.Where("logged_at >= ?", query.Since)
} }
if !query.Until.IsZero() {
db = db.Where("logged_at < ?", query.Until)
}
return db return db
} }
@@ -392,6 +438,75 @@ func buildNodeAccessLogBucketRows(query NodeAccessLogBucketQuery) ([]*NodeAccess
return rows, nil 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) { func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSince time.Time) ([]*NodeAccessLogIPSummaryRow, error) {
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{ logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
NodeID: query.NodeID, NodeID: query.NodeID,
@@ -442,6 +557,36 @@ func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSi
return rows, nil 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) { func sortNodeAccessLogs(items []*NodeAccessLog, sortBy string, sortOrder string) {
desc := normalizeSortOrder(sortOrder) != "asc" desc := normalizeSortOrder(sortOrder) != "asc"
sort.Slice(items, func(i int, j int) bool { sort.Slice(items, func(i int, j int) bool {
+1
View File
@@ -184,6 +184,7 @@ func SetApiRouter(router *gin.Engine) {
{ {
accessLogRoute.GET("/", controller.GetAccessLogs) accessLogRoute.GET("/", controller.GetAccessLogs)
accessLogRoute.GET("/folds", controller.GetFoldedAccessLogs) accessLogRoute.GET("/folds", controller.GetFoldedAccessLogs)
accessLogRoute.GET("/folds/ip-summary", controller.GetFoldedAccessLogIPs)
accessLogRoute.GET("/ip-summary", controller.GetAccessLogIPSummaries) accessLogRoute.GET("/ip-summary", controller.GetAccessLogIPSummaries)
accessLogRoute.GET("/ip-summary/trend", controller.GetAccessLogIPTrend) accessLogRoute.GET("/ip-summary/trend", controller.GetAccessLogIPTrend)
accessLogRoute.POST("/cleanup", controller.CleanupAccessLogs) accessLogRoute.POST("/cleanup", controller.CleanupAccessLogs)
+116 -1
View File
@@ -73,6 +73,40 @@ type FoldedAccessLogList struct {
FoldMinutes int `json:"fold_minutes"` 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 { type AccessLogIPSummaryQuery struct {
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"` RemoteAddr string `json:"remote_addr"`
@@ -187,7 +221,7 @@ func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) {
Since: modelQuery.Since, Since: modelQuery.Since,
Page: normalized.Page, Page: normalized.Page,
PageSize: normalized.PageSize, PageSize: normalized.PageSize,
SortBy: normalizeFoldSortBy(normalized.SortBy), SortBy: normalizeFoldSortBy(input.SortBy),
SortOrder: normalized.SortOrder, SortOrder: normalized.SortOrder,
FoldMinutes: foldMinutes, FoldMinutes: foldMinutes,
} }
@@ -230,6 +264,58 @@ func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) {
}, nil }, 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) { func ListAccessLogIPSummaries(input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) {
normalized := normalizeAccessLogIPSummaryQuery(input) normalized := normalizeAccessLogIPSummaryQuery(input)
since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow) 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) { func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) {
remoteAddr := strings.TrimSpace(input.RemoteAddr) remoteAddr := strings.TrimSpace(input.RemoteAddr)
if remoteAddr == "" { if remoteAddr == "" {
@@ -179,6 +179,8 @@ func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
NodeID: "node-folded", NodeID: "node-folded",
Page: 0, Page: 0,
PageSize: 10, PageSize: 10,
SortBy: "request_count",
SortOrder: "desc",
FoldMinutes: 5, FoldMinutes: 5,
}) })
if err != nil { if err != nil {
@@ -193,6 +195,28 @@ func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
if folded.Items[0].RequestCount+folded.Items[1].RequestCount != 3 { if folded.Items[0].RequestCount+folded.Items[1].RequestCount != 3 {
t.Fatalf("unexpected folded request count sum: %+v", folded.Items) 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{ ipSummaries, err := ListAccessLogIPSummaries(AccessLogIPSummaryQuery{
NodeID: "node-folded", NodeID: "node-folded",
@@ -9,13 +9,17 @@ import type {
AccessLogIPTrend, AccessLogIPTrend,
AccessLogIPTrendFilters, AccessLogIPTrendFilters,
AccessLogList, AccessLogList,
FoldedAccessLogIPFilters,
FoldedAccessLogIPList,
FoldedAccessLogFilters, FoldedAccessLogFilters,
FoldedAccessLogList, FoldedAccessLogList,
} from '@/features/access-logs/types'; } from '@/features/access-logs/types';
function buildSearchParams(filters: object) { function buildSearchParams(filters: object) {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
Object.entries(filters as Record<string, string | number | undefined>).forEach(([key, value]) => { Object.entries(
filters as Record<string, string | number | undefined>,
).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') { if (value === undefined || value === null || value === '') {
return; return;
} }
@@ -31,7 +35,16 @@ export function getAccessLogs(filters: AccessLogFilters) {
export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) { export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) {
const query = buildSearchParams(filters); const query = buildSearchParams(filters);
return apiRequest<FoldedAccessLogList>(`/access-logs/folds${query ? `?${query}` : ''}`); return apiRequest<FoldedAccessLogList>(
`/access-logs/folds${query ? `?${query}` : ''}`,
);
}
export function getFoldedAccessLogIPs(filters: FoldedAccessLogIPFilters) {
const query = buildSearchParams(filters);
return apiRequest<FoldedAccessLogIPList>(
`/access-logs/folds/ip-summary${query ? `?${query}` : ''}`,
);
} }
export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) { export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) {
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { Fragment, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TrendChart } from '@/components/data/trend-chart'; import { TrendChart } from '@/components/data/trend-chart';
@@ -16,6 +16,7 @@ import {
getAccessLogIPSummaries, getAccessLogIPSummaries,
getAccessLogIPTrend, getAccessLogIPTrend,
getAccessLogs, getAccessLogs,
getFoldedAccessLogIPs,
getFoldedAccessLogs, getFoldedAccessLogs,
} from '@/features/access-logs/api/access-logs'; } from '@/features/access-logs/api/access-logs';
import type { import type {
@@ -23,6 +24,7 @@ import type {
AccessLogIPSummaryItem, AccessLogIPSummaryItem,
AccessLogIPSummaryList, AccessLogIPSummaryList,
AccessLogList, AccessLogList,
FoldedAccessLogIPList,
FoldedAccessLogList, FoldedAccessLogList,
} from '@/features/access-logs/types'; } from '@/features/access-logs/types';
import { import {
@@ -94,7 +96,10 @@ function buildSummary(totalRecord = 0, totalIP = 0, activeTab: ActiveTab) {
return [ return [
{ label: '访问记录', value: formatCompactNumber(totalRecord) }, { label: '访问记录', value: formatCompactNumber(totalRecord) },
{ label: '来源 IP', value: formatCompactNumber(totalIP) }, { 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<FoldedAccessLogList['items'][number]>;
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<AccessLogList['items'][number]>;
return (
typeof candidate.logged_at === 'string' &&
typeof candidate.status_code === 'number'
);
});
}
export function AccessLogsPage() { export function AccessLogsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<ActiveTab>('detail'); const [activeTab, setActiveTab] = useState<ActiveTab>('detail');
@@ -134,7 +169,13 @@ export function AccessLogsPage() {
const [detailSort, setDetailSort] = useState('logged_at:desc'); const [detailSort, setDetailSort] = useState('logged_at:desc');
const [foldedSort, setFoldedSort] = useState('bucket_started_at:desc'); const [foldedSort, setFoldedSort] = useState('bucket_started_at:desc');
const [ipSort, setIPSort] = useState('total_requests:desc'); const [ipSort, setIPSort] = useState('total_requests:desc');
const [selectedIP, setSelectedIP] = useState<AccessLogIPSummaryItem | null>(null); const [expandedBucket, setExpandedBucket] = useState<string | null>(null);
const [bucketIPPages, setBucketIPPages] = useState<Record<string, number>>(
{},
);
const [selectedIP, setSelectedIP] = useState<AccessLogIPSummaryItem | null>(
null,
);
const [cleanupDays, setCleanupDays] = useState<string>('7'); const [cleanupDays, setCleanupDays] = useState<string>('7');
const [customCleanupDays, setCustomCleanupDays] = useState('14'); const [customCleanupDays, setCustomCleanupDays] = useState('14');
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false); const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
@@ -179,8 +220,14 @@ export function AccessLogsPage() {
sort_order: detailSortState.sortOrder, sort_order: detailSortState.sortOrder,
}); });
}, },
placeholderData: (previousData: AccessLogList | FoldedAccessLogList | undefined) => placeholderData: (
previousData, previousData: AccessLogList | FoldedAccessLogList | undefined,
) => {
if (foldMinutes > 0) {
return isFoldedAccessLogList(previousData) ? previousData : undefined;
}
return isAccessLogList(previousData) ? previousData : undefined;
},
}); });
const ipSummaryQuery = useQuery<AccessLogIPSummaryList>({ const ipSummaryQuery = useQuery<AccessLogIPSummaryList>({
@@ -195,11 +242,18 @@ export function AccessLogsPage() {
sort_by: ipSortState.sortBy, sort_by: ipSortState.sortBy,
sort_order: ipSortState.sortOrder, sort_order: ipSortState.sortOrder,
}), }),
placeholderData: (previousData: AccessLogIPSummaryList | undefined) => previousData, placeholderData: (previousData: AccessLogIPSummaryList | undefined) =>
previousData,
}); });
const ipTrendQuery = useQuery({ 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: () => queryFn: () =>
getAccessLogIPTrend({ getAccessLogIPTrend({
node_id: filters.nodeId || undefined, node_id: filters.nodeId || undefined,
@@ -212,18 +266,26 @@ export function AccessLogsPage() {
}); });
const cleanupMutation = useMutation({ const cleanupMutation = useMutation({
mutationFn: (payload: AccessLogCleanupPayload) => cleanupAccessLogs(payload), mutationFn: (payload: AccessLogCleanupPayload) =>
cleanupAccessLogs(payload),
onSuccess: async () => { onSuccess: async () => {
setCleanupModalOpen(false); setCleanupModalOpen(false);
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['access-logs', 'detail'] }), queryClient.invalidateQueries({ queryKey: ['access-logs', 'detail'] }),
queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-summary'] }), queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-trend'] }), 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( const summary = useMemo(
() => () =>
buildSummary( buildSummary(
@@ -252,6 +314,8 @@ export function AccessLogsPage() {
}); });
setDetailPage(0); setDetailPage(0);
setIPPage(0); setIPPage(0);
setExpandedBucket(null);
setBucketIPPages({});
}; };
const handleReset = () => { const handleReset = () => {
@@ -261,6 +325,8 @@ export function AccessLogsPage() {
setDetailPage(0); setDetailPage(0);
setIPPage(0); setIPPage(0);
setSelectedIP(null); setSelectedIP(null);
setExpandedBucket(null);
setBucketIPPages({});
}; };
const handleCleanupConfirm = () => { const handleCleanupConfirm = () => {
@@ -277,13 +343,19 @@ export function AccessLogsPage() {
title="日志" title="日志"
description="升级后的日志中心支持按节点、IP、域名与路径检索,支持时间折叠、IP 维度聚合与按保留天数清理旧日志。" description="升级后的日志中心支持按节点、IP、域名与路径检索,支持时间折叠、IP 维度聚合与按保留天数清理旧日志。"
action={ action={
<PrimaryButton type="button" onClick={() => setCleanupModalOpen(true)}> <PrimaryButton
type="button"
onClick={() => setCleanupModalOpen(true)}
>
清理日志 清理日志
</PrimaryButton> </PrimaryButton>
} }
/> />
<AppCard title="日志摘要" description="所有汇总、排序、折叠与分页都由后端计算,前端仅展示当前页结果。"> <AppCard
title="日志摘要"
description="所有汇总、排序、折叠与分页都由后端计算,前端仅展示当前页结果。"
>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
{summary.map((item) => ( {summary.map((item) => (
<div <div
@@ -320,8 +392,16 @@ export function AccessLogsPage() {
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{[ {[
{ key: 'detail', label: '明细日志', description: '按请求明细查看与折叠聚合' }, {
{ key: 'ip', label: 'IP 维度', description: '查看来源 IP 汇总与趋势' }, key: 'detail',
label: '明细日志',
description: '按请求明细查看与折叠聚合',
},
{
key: 'ip',
label: 'IP 维度',
description: '查看来源 IP 汇总与趋势',
},
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.key} key={tab.key}
@@ -344,7 +424,10 @@ export function AccessLogsPage() {
<ResourceInput <ResourceInput
value={draft.nodeId} value={draft.nodeId}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ ...current, nodeId: event.target.value })) setDraft((current) => ({
...current,
nodeId: event.target.value,
}))
} }
placeholder="按 node_id 搜索" placeholder="按 node_id 搜索"
/> />
@@ -353,7 +436,10 @@ export function AccessLogsPage() {
<ResourceInput <ResourceInput
value={draft.remoteAddr} value={draft.remoteAddr}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ ...current, remoteAddr: event.target.value })) setDraft((current) => ({
...current,
remoteAddr: event.target.value,
}))
} }
placeholder="按 IP 搜索" placeholder="按 IP 搜索"
/> />
@@ -362,20 +448,30 @@ export function AccessLogsPage() {
<ResourceInput <ResourceInput
value={draft.host} value={draft.host}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ ...current, host: event.target.value })) setDraft((current) => ({
...current,
host: event.target.value,
}))
} }
placeholder="按域名搜索" placeholder="按域名搜索"
/> />
</ResourceField> </ResourceField>
<ResourceField <ResourceField
label="请求路径" label="请求路径"
hint={activeTab === 'ip' ? 'IP 维度页暂不使用路径过滤。' : '支持按路径模糊过滤。'} hint={
activeTab === 'ip'
? 'IP 维度页暂不使用路径过滤。'
: '支持按路径模糊过滤。'
}
> >
<ResourceInput <ResourceInput
value={draft.path} value={draft.path}
disabled={activeTab === 'ip'} disabled={activeTab === 'ip'}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ ...current, path: event.target.value })) setDraft((current) => ({
...current,
path: event.target.value,
}))
} }
placeholder="按路径搜索" placeholder="按路径搜索"
/> />
@@ -400,7 +496,11 @@ export function AccessLogsPage() {
</ResourceSelect> </ResourceSelect>
</ResourceField> </ResourceField>
<ResourceField label={activeTab === 'detail' && foldMinutes > 0 ? '折叠排序' : '排序'}> <ResourceField
label={
activeTab === 'detail' && foldMinutes > 0 ? '折叠排序' : '排序'
}
>
<ResourceSelect <ResourceSelect
value={ value={
activeTab === 'detail' && foldMinutes > 0 activeTab === 'detail' && foldMinutes > 0
@@ -444,6 +544,8 @@ export function AccessLogsPage() {
onChange={(event) => { onChange={(event) => {
setFoldMinutes(Number(event.target.value) as 0 | 3 | 5); setFoldMinutes(Number(event.target.value) as 0 | 3 | 5);
setDetailPage(0); setDetailPage(0);
setExpandedBucket(null);
setBucketIPPages({});
}} }}
> >
{foldOptions.map((option) => ( {foldOptions.map((option) => (
@@ -474,7 +576,19 @@ export function AccessLogsPage() {
detailPage={detailPage} detailPage={detailPage}
pageSize={pageSize} pageSize={pageSize}
foldMinutes={foldMinutes} foldMinutes={foldMinutes}
filters={filters}
query={detailQuery} query={detailQuery}
expandedBucket={expandedBucket}
bucketIPPages={bucketIPPages}
onToggleBucket={(bucket) =>
setExpandedBucket((current) => (current === bucket ? null : bucket))
}
onBucketIPPageChange={(bucket, nextPage) =>
setBucketIPPages((current) => ({
...current,
[bucket]: Math.max(nextPage, 0),
}))
}
onPrevPage={() => setDetailPage((value) => Math.max(value - 1, 0))} onPrevPage={() => setDetailPage((value) => Math.max(value - 1, 0))}
onNextPage={() => setDetailPage((value) => value + 1)} onNextPage={() => setDetailPage((value) => value + 1)}
/> />
@@ -499,7 +613,10 @@ export function AccessLogsPage() {
{ipTrendQuery.isLoading ? ( {ipTrendQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : ipTrendQuery.isError ? ( ) : ipTrendQuery.isError ? (
<ErrorState title="IP 趋势加载失败" description={getErrorMessage(ipTrendQuery.error)} /> <ErrorState
title="IP 趋势加载失败"
description={getErrorMessage(ipTrendQuery.error)}
/>
) : ipTrendQuery.data ? ( ) : ipTrendQuery.data ? (
<TrendChart <TrendChart
labels={trendLabels} labels={trendLabels}
@@ -515,7 +632,10 @@ export function AccessLogsPage() {
yAxisValueFormatter={(value) => formatCompactNumber(value)} yAxisValueFormatter={(value) => formatCompactNumber(value)}
/> />
) : ( ) : (
<EmptyState title="暂无趋势数据" description="当前 IP 在最近 24 小时内没有可展示的访问曲线。" /> <EmptyState
title="暂无趋势数据"
description="当前 IP 在最近 24 小时内没有可展示的访问曲线。"
/>
)} )}
</AppModal> </AppModal>
@@ -526,7 +646,10 @@ export function AccessLogsPage() {
onClose={() => setCleanupModalOpen(false)} onClose={() => setCleanupModalOpen(false)}
footer={ footer={
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<SecondaryButton type="button" onClick={() => setCleanupModalOpen(false)}> <SecondaryButton
type="button"
onClick={() => setCleanupModalOpen(false)}
>
取消 取消
</SecondaryButton> </SecondaryButton>
<PrimaryButton <PrimaryButton
@@ -607,13 +730,19 @@ function DetailTab({
detailPage, detailPage,
pageSize, pageSize,
foldMinutes, foldMinutes,
filters,
query, query,
expandedBucket,
bucketIPPages,
onToggleBucket,
onBucketIPPageChange,
onPrevPage, onPrevPage,
onNextPage, onNextPage,
}: { }: {
detailPage: number; detailPage: number;
pageSize: number; pageSize: number;
foldMinutes: 0 | 3 | 5; foldMinutes: 0 | 3 | 5;
filters: AppliedSearch;
query: { query: {
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
@@ -621,6 +750,10 @@ function DetailTab({
error: unknown; error: unknown;
data?: AccessLogList | FoldedAccessLogList; data?: AccessLogList | FoldedAccessLogList;
}; };
expandedBucket: string | null;
bucketIPPages: Record<string, number>;
onToggleBucket: (bucket: string) => void;
onBucketIPPageChange: (bucket: string, page: number) => void;
onPrevPage: () => void; onPrevPage: () => void;
onNextPage: () => void; onNextPage: () => void;
}) { }) {
@@ -635,13 +768,26 @@ function DetailTab({
if (query.isError) { if (query.isError) {
return ( return (
<AppCard title="访问日志" description="日志查询失败。"> <AppCard title="访问日志" description="日志查询失败。">
<ErrorState title="访问日志加载失败" description={getErrorMessage(query.error)} /> <ErrorState
title="访问日志加载失败"
description={getErrorMessage(query.error)}
/>
</AppCard> </AppCard>
); );
} }
if (foldMinutes > 0) { if (foldMinutes > 0) {
const data = query.data as FoldedAccessLogList; if (!isFoldedAccessLogList(query.data)) {
return (
<AppCard
title="折叠日志"
description={`当前按 ${foldMinutes} 分钟时间桶折叠,适合在高频刷新时快速定位异常波段。`}
>
<LoadingState />
</AppCard>
);
}
const data = query.data;
return ( return (
<AppCard <AppCard
title="折叠日志" title="折叠日志"
@@ -649,7 +795,10 @@ function DetailTab({
> >
<div className="space-y-4"> <div className="space-y-4">
{data.items.length === 0 ? ( {data.items.length === 0 ? (
<EmptyState title="暂无折叠日志" description="当前筛选条件下没有可展示的时间桶。" /> <EmptyState
title="暂无折叠日志"
description="当前筛选条件下没有可展示的时间桶。"
/>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm"> <table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
@@ -665,34 +814,83 @@ function DetailTab({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--border-default)]"> <tbody className="divide-y divide-[var(--border-default)]">
{data.items.map((item) => ( {data.items.map((item) => {
<tr key={item.bucket_started_at} className="align-top"> const isExpanded =
<td className="px-3 py-4 text-[var(--foreground-secondary)]"> expandedBucket === item.bucket_started_at;
<div>{formatDateTime(item.bucket_started_at)}</div> const bucketPage =
<div className="mt-1 text-xs text-[var(--foreground-muted)]"> bucketIPPages[item.bucket_started_at] ?? 0;
{formatRelativeTime(item.bucket_started_at)} return (
</div> <Fragment key={item.bucket_started_at}>
</td> <tr
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]"> key={item.bucket_started_at}
{formatCompactNumber(item.request_count)} className="cursor-pointer align-top transition hover:bg-[var(--surface-elevated)]"
</td> onClick={() => onToggleBucket(item.bucket_started_at)}
<td className="px-3 py-4 text-[var(--foreground-secondary)]"> >
{formatCompactNumber(item.unique_ip_count)} <td className="px-3 py-4 text-[var(--foreground-secondary)]">
</td> <button
<td className="px-3 py-4 text-[var(--foreground-secondary)]"> type="button"
{formatCompactNumber(item.unique_host_count)} onClick={(event) => {
</td> event.stopPropagation();
<td className="px-3 py-4 text-emerald-600"> onToggleBucket(item.bucket_started_at);
{formatCompactNumber(item.success_count)} }}
</td> className="text-left font-medium text-[var(--foreground-primary)] underline-offset-4 hover:underline"
<td className="px-3 py-4 text-amber-600"> >
{formatCompactNumber(item.client_error_count)} {formatDateTime(item.bucket_started_at)}
</td> </button>
<td className="px-3 py-4 text-rose-600"> <div className="mt-1 text-xs text-[var(--foreground-muted)]">
{formatCompactNumber(item.server_error_count)} {formatRelativeTime(item.bucket_started_at)}
</td> </div>
</tr> </td>
))} <td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{formatCompactNumber(item.request_count)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.unique_ip_count)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.unique_host_count)}
</td>
<td className="px-3 py-4 text-emerald-600">
{formatCompactNumber(item.success_count)}
</td>
<td className="px-3 py-4 text-amber-600">
{formatCompactNumber(item.client_error_count)}
</td>
<td className="px-3 py-4 text-rose-600">
{formatCompactNumber(item.server_error_count)}
</td>
</tr>
{isExpanded ? (
<tr key={`${item.bucket_started_at}-detail`}>
<td
colSpan={7}
className="bg-[var(--surface-elevated)] px-3 py-4"
>
<FoldedBucketIPDetails
bucketStartedAt={item.bucket_started_at}
foldMinutes={foldMinutes as 3 | 5}
filters={filters}
page={bucketPage}
pageSize={pageSize}
onPrevPage={() =>
onBucketIPPageChange(
item.bucket_started_at,
bucketPage - 1,
)
}
onNextPage={() =>
onBucketIPPageChange(
item.bucket_started_at,
bucketPage + 1,
)
}
/>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -701,6 +899,7 @@ function DetailTab({
page={detailPage} page={detailPage}
pageSize={pageSize} pageSize={pageSize}
hasMore={data.has_more} hasMore={data.has_more}
itemCount={data.items.length}
isFetching={query.isFetching} isFetching={query.isFetching}
onPrev={onPrevPage} onPrev={onPrevPage}
onNext={onNextPage} onNext={onNextPage}
@@ -710,7 +909,18 @@ function DetailTab({
); );
} }
const data = query.data as AccessLogList; if (!isAccessLogList(query.data)) {
return (
<AppCard
title="访问明细"
description="展示原始访问明细,支持按时间、状态码、IP 与域名排序。"
>
<LoadingState />
</AppCard>
);
}
const data = query.data;
return ( return (
<AppCard <AppCard
title="访问明细" title="访问明细"
@@ -718,7 +928,10 @@ function DetailTab({
> >
<div className="space-y-4"> <div className="space-y-4">
{data.items.length === 0 ? ( {data.items.length === 0 ? (
<EmptyState title="暂无访问日志" description="当前筛选条件下没有可展示的访问明细。" /> <EmptyState
title="暂无访问日志"
description="当前筛选条件下没有可展示的访问明细。"
/>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm"> <table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
@@ -769,7 +982,10 @@ function DetailTab({
</div> </div>
</td> </td>
<td className="px-3 py-4"> <td className="px-3 py-4">
<StatusBadge label={statusMeta.label} variant={statusMeta.variant} /> <StatusBadge
label={statusMeta.label}
variant={statusMeta.variant}
/>
</td> </td>
</tr> </tr>
); );
@@ -782,6 +998,7 @@ function DetailTab({
page={detailPage} page={detailPage}
pageSize={pageSize} pageSize={pageSize}
hasMore={data.has_more} hasMore={data.has_more}
itemCount={data.items.length}
isFetching={query.isFetching} isFetching={query.isFetching}
onPrev={onPrevPage} onPrev={onPrevPage}
onNext={onNextPage} onNext={onNextPage}
@@ -826,9 +1043,15 @@ function IPTab({
{query.isLoading ? ( {query.isLoading ? (
<LoadingState /> <LoadingState />
) : query.isError ? ( ) : query.isError ? (
<ErrorState title="IP 汇总加载失败" description={getErrorMessage(query.error)} /> <ErrorState
title="IP 汇总加载失败"
description={getErrorMessage(query.error)}
/>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<EmptyState title="暂无 IP 访问记录" description="当前筛选条件下没有可展示的来源 IP。" /> <EmptyState
title="暂无 IP 访问记录"
description="当前筛选条件下没有可展示的来源 IP。"
/>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -872,6 +1095,7 @@ function IPTab({
page={ipPage} page={ipPage}
pageSize={pageSize} pageSize={pageSize}
hasMore={hasMore} hasMore={hasMore}
itemCount={items.length}
isFetching={query.isFetching} isFetching={query.isFetching}
onPrev={onPrevPage} onPrev={onPrevPage}
onNext={onNextPage} onNext={onNextPage}
@@ -882,10 +1106,137 @@ function IPTab({
); );
} }
function FoldedBucketIPDetails({
bucketStartedAt,
foldMinutes,
filters,
page,
pageSize,
onPrevPage,
onNextPage,
}: {
bucketStartedAt: string;
foldMinutes: 3 | 5;
filters: AppliedSearch;
page: number;
pageSize: number;
onPrevPage: () => void;
onNextPage: () => void;
}) {
const query = useQuery<FoldedAccessLogIPList>({
queryKey: [
'access-logs',
'fold-bucket-ip',
filters,
bucketStartedAt,
foldMinutes,
page,
pageSize,
],
queryFn: () =>
getFoldedAccessLogIPs({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
bucket_started_at: bucketStartedAt,
fold_minutes: foldMinutes,
p: page,
page_size: pageSize,
sort_by: 'request_count',
sort_order: 'desc',
}),
placeholderData: (previousData: FoldedAccessLogIPList | undefined) =>
previousData,
});
if (query.isLoading) {
return <LoadingState />;
}
if (query.isError) {
return (
<ErrorState
title="时间桶 IP 明细加载失败"
description={getErrorMessage(query.error)}
/>
);
}
const items = query.data?.items ?? [];
if (items.length === 0) {
return (
<EmptyState
title="暂无 IP 明细"
description="该时间段内没有可展示的来源 IP。"
/>
);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
时间段内 IP 访问情况
</p>
<p className="text-xs text-[var(--foreground-muted)]">
共 {formatCompactNumber(query.data?.total_ip ?? 0)} 个
IP,按访问次数从高到低排序
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">来源 IP</th>
<th className="px-3 py-3 font-medium">访问次数</th>
<th className="px-3 py-3 font-medium">2xx</th>
<th className="px-3 py-3 font-medium">4xx</th>
<th className="px-3 py-3 font-medium">5xx</th>
<th className="px-3 py-3 font-medium">最后访问</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{items.map((item) => (
<tr key={item.remote_addr}>
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
{item.remote_addr}
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.request_count)}
</td>
<td className="px-3 py-3 text-emerald-600">
{formatCompactNumber(item.success_count)}
</td>
<td className="px-3 py-3 text-amber-600">
{formatCompactNumber(item.client_error_count)}
</td>
<td className="px-3 py-3 text-rose-600">
{formatCompactNumber(item.server_error_count)}
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
{formatDateTime(item.last_seen_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager
page={page}
pageSize={pageSize}
hasMore={query.data?.has_more ?? false}
itemCount={items.length}
isFetching={query.isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
);
}
function Pager({ function Pager({
page, page,
pageSize, pageSize,
hasMore, hasMore,
itemCount,
isFetching, isFetching,
onPrev, onPrev,
onNext, onNext,
@@ -893,20 +1244,30 @@ function Pager({
page: number; page: number;
pageSize: number; pageSize: number;
hasMore: boolean; hasMore: boolean;
itemCount: number;
isFetching: boolean; isFetching: boolean;
onPrev: () => void; onPrev: () => void;
onNext: () => void; onNext: () => void;
}) { }) {
const canTryNext = hasMore || itemCount >= pageSize;
return ( return (
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-[var(--foreground-secondary)]"> <p className="text-sm text-[var(--foreground-secondary)]">
第 {page + 1} 页,每页 {pageSize} 条。 第 {page + 1} 页,每页 {pageSize} 条。
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<SecondaryButton type="button" disabled={page === 0 || isFetching} onClick={onPrev}> <SecondaryButton
type="button"
disabled={page === 0 || isFetching}
onClick={onPrev}
>
上一页 上一页
</SecondaryButton> </SecondaryButton>
<SecondaryButton type="button" disabled={!hasMore || isFetching} onClick={onNext}> <SecondaryButton
type="button"
disabled={!canTryNext || isFetching}
onClick={onNext}
>
下一页 下一页
</SecondaryButton> </SecondaryButton>
</div> </div>
@@ -55,6 +55,31 @@ export interface FoldedAccessLogList {
fold_minutes: number; fold_minutes: number;
} }
export interface FoldedAccessLogIPFilters extends FoldedAccessLogFilters {
bucket_started_at: string;
}
export interface FoldedAccessLogIPItem {
remote_addr: string;
request_count: number;
success_count: number;
client_error_count: number;
server_error_count: number;
last_seen_at: string;
}
export interface FoldedAccessLogIPList {
items: FoldedAccessLogIPItem[];
page: number;
page_size: number;
has_more: boolean;
total_ip: number;
bucket_started_at: string;
fold_minutes: number;
sort_by: string;
sort_order: 'asc' | 'desc';
}
export interface AccessLogIPSummaryFilters { export interface AccessLogIPSummaryFilters {
node_id?: string; node_id?: string;
remote_addr?: string; remote_addr?: string;