feat: 添加访问日志分页功能,优化相关接口和前端组件

This commit is contained in:
ryan
2026-03-14 23:18:49 +08:00
parent b7d38590ba
commit 8d406f5ade
8 changed files with 184 additions and 23 deletions
+10 -3
View File
@@ -1,8 +1,11 @@
package controller package controller
import "atsflare/service" import (
"atsflare/service"
"strconv"
import "github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
)
// GetAccessLogs godoc // GetAccessLogs godoc
// @Summary List access logs // @Summary List access logs
@@ -10,10 +13,14 @@ import "github.com/gin-gonic/gin"
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param node_id query string false "Node ID" // @Param node_id query string false "Node ID"
// @Param p query int false "Page index"
// @Param page_size query int false "Page size"
// @Success 200 {object} map[string]interface{} // @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ [get] // @Router /api/access-logs/ [get]
func GetAccessLogs(c *gin.Context) { func GetAccessLogs(c *gin.Context) {
logs, err := service.ListAccessLogs(c.Query("node_id")) page, _ := strconv.Atoi(c.DefaultQuery("p", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "0"))
logs, err := service.ListAccessLogs(c.Query("node_id"), page, pageSize)
if err != nil { if err != nil {
respondFailure(c, err.Error()) respondFailure(c, err.Error())
return return
+4 -1
View File
@@ -14,7 +14,7 @@ type NodeAccessLog struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
func ListNodeAccessLogs(nodeID string, since time.Time, limit int) (logs []*NodeAccessLog, err error) { func ListNodeAccessLogs(nodeID string, since time.Time, offset int, limit int) (logs []*NodeAccessLog, err error) {
query := DB.Order("logged_at desc, id desc") query := DB.Order("logged_at desc, id desc")
if nodeID != "" { if nodeID != "" {
query = query.Where("node_id = ?", nodeID) query = query.Where("node_id = ?", nodeID)
@@ -22,6 +22,9 @@ func ListNodeAccessLogs(nodeID string, since time.Time, limit int) (logs []*Node
if !since.IsZero() { if !since.IsZero() {
query = query.Where("logged_at >= ?", since) query = query.Where("logged_at >= ?", since)
} }
if offset > 0 {
query = query.Offset(offset)
}
if limit > 0 { if limit > 0 {
query = query.Limit(limit) query = query.Limit(limit)
} }
+48 -4
View File
@@ -6,7 +6,10 @@ import (
"time" "time"
) )
const accessLogListLimit = 500 const (
defaultAccessLogPageSize = 50
maxAccessLogPageSize = 200
)
type AccessLogView struct { type AccessLogView struct {
ID uint `json:"id"` ID uint `json:"id"`
@@ -19,8 +22,23 @@ type AccessLogView struct {
StatusCode int `json:"status_code"` StatusCode int `json:"status_code"`
} }
func ListAccessLogs(nodeID string) ([]AccessLogView, error) { type AccessLogList struct {
logs, err := model.ListNodeAccessLogs(strings.TrimSpace(nodeID), time.Now().Add(-nodeAccessLogRetentionWindow), accessLogListLimit) Items []AccessLogView `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
}
func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, error) {
normalizedPage := normalizeAccessLogPage(page)
normalizedPageSize := normalizeAccessLogPageSize(pageSize)
offset := normalizedPage * normalizedPageSize
logs, err := model.ListNodeAccessLogs(
strings.TrimSpace(nodeID),
time.Now().Add(-nodeAccessLogRetentionWindow),
offset,
normalizedPageSize+1,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -35,6 +53,10 @@ func ListAccessLogs(nodeID string) ([]AccessLogView, error) {
} }
nodeNames[node.NodeID] = node.Name nodeNames[node.NodeID] = node.Name
} }
hasMore := len(logs) > normalizedPageSize
if hasMore {
logs = logs[:normalizedPageSize]
}
views := make([]AccessLogView, 0, len(logs)) views := make([]AccessLogView, 0, len(logs))
for _, item := range logs { for _, item := range logs {
if item == nil { if item == nil {
@@ -51,5 +73,27 @@ func ListAccessLogs(nodeID string) ([]AccessLogView, error) {
StatusCode: item.StatusCode, StatusCode: item.StatusCode,
}) })
} }
return views, nil return &AccessLogList{
Items: views,
Page: normalizedPage,
PageSize: normalizedPageSize,
HasMore: hasMore,
}, nil
}
func normalizeAccessLogPage(page int) int {
if page < 0 {
return 0
}
return page
}
func normalizeAccessLogPageSize(pageSize int) int {
if pageSize <= 0 {
return defaultAccessLogPageSize
}
if pageSize > maxAccessLogPageSize {
return maxAccessLogPageSize
}
return pageSize
} }
+71 -2
View File
@@ -673,7 +673,7 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
t.Fatalf("unexpected request reports: %+v", reports) t.Fatalf("unexpected request reports: %+v", reports)
} }
accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 10) accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 0, 10)
if err != nil { if err != nil {
t.Fatalf("expected node access logs query to succeed: %v", err) t.Fatalf("expected node access logs query to succeed: %v", err)
} }
@@ -780,7 +780,7 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
t.Fatalf("expected current and buffered reports, got %+v", reports) t.Fatalf("expected current and buffered reports, got %+v", reports)
} }
accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 10) accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 0, 10)
if err != nil { if err != nil {
t.Fatalf("expected node access logs query to succeed: %v", err) t.Fatalf("expected node access logs query to succeed: %v", err)
} }
@@ -836,6 +836,75 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
} }
} }
func TestListAccessLogsUsesPagination(t *testing.T) {
setupServiceTestDB(t)
node := &model.Node{
NodeID: "node-access-log-page",
Name: "access-log-edge",
IP: "10.0.0.40",
AgentToken: "token-access-log-page",
AgentVersion: "v0.6.0",
NginxVersion: "1.27.1.2",
Status: NodeStatusOnline,
}
if err := node.Insert(); err != nil {
t.Fatalf("failed to seed node: %v", err)
}
now := time.Now().UTC()
if err := model.DB.Create([]*model.NodeAccessLog{
{
NodeID: node.NodeID,
LoggedAt: now.Add(-10 * time.Second),
RemoteAddr: "203.0.113.1",
Host: "example.com",
Path: "/one",
StatusCode: 200,
},
{
NodeID: node.NodeID,
LoggedAt: now.Add(-9 * time.Second),
RemoteAddr: "203.0.113.2",
Host: "example.com",
Path: "/two",
StatusCode: 200,
},
{
NodeID: node.NodeID,
LoggedAt: now.Add(-8 * time.Second),
RemoteAddr: "203.0.113.3",
Host: "example.com",
Path: "/three",
StatusCode: 502,
},
}).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
}
pageOne, err := ListAccessLogs(node.NodeID, 0, 2)
if err != nil {
t.Fatalf("ListAccessLogs page 1 failed: %v", err)
}
if len(pageOne.Items) != 2 || !pageOne.HasMore {
t.Fatalf("unexpected first page: %+v", pageOne)
}
if pageOne.Items[0].Path != "/three" || pageOne.Items[1].Path != "/two" {
t.Fatalf("unexpected first page ordering: %+v", pageOne.Items)
}
pageTwo, err := ListAccessLogs(node.NodeID, 1, 2)
if err != nil {
t.Fatalf("ListAccessLogs page 2 failed: %v", err)
}
if len(pageTwo.Items) != 1 || pageTwo.HasMore {
t.Fatalf("unexpected second page: %+v", pageTwo)
}
if pageTwo.Items[0].Path != "/one" {
t.Fatalf("unexpected second page ordering: %+v", pageTwo.Items)
}
}
func TestHeartbeatNodeResolvesMissingHealthEvents(t *testing.T) { func TestHeartbeatNodeResolvesMissingHealthEvents(t *testing.T) {
setupServiceTestDB(t) setupServiceTestDB(t)
@@ -1,11 +1,15 @@
import { apiRequest } from '@/lib/api/client'; import { apiRequest } from '@/lib/api/client';
import type { AccessLogItem } from '@/features/access-logs/types'; import type { AccessLogList } from '@/features/access-logs/types';
export function getAccessLogs(nodeId?: string) { export function getAccessLogs(page: number, nodeId?: string, pageSize = 50) {
const normalizedNodeId = nodeId?.trim(); const normalizedNodeId = nodeId?.trim();
const query = normalizedNodeId const searchParams = new URLSearchParams({
? `?node_id=${encodeURIComponent(normalizedNodeId)}` p: String(Math.max(page, 0)),
: ''; page_size: String(pageSize),
return apiRequest<AccessLogItem[]>(`/access-logs/${query}`); });
if (normalizedNodeId) {
searchParams.set('node_id', normalizedNodeId);
}
return apiRequest<AccessLogList>(`/access-logs/?${searchParams.toString()}`);
} }
@@ -54,13 +54,16 @@ export function AccessLogsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [nodeFilterInput, setNodeFilterInput] = useState(''); const [nodeFilterInput, setNodeFilterInput] = useState('');
const [nodeFilter, setNodeFilter] = useState(''); const [nodeFilter, setNodeFilter] = useState('');
const [page, setPage] = useState(0);
const logsQuery = useQuery({ const logsQuery = useQuery({
queryKey: accessLogsQueryKey(nodeFilter), queryKey: [...accessLogsQueryKey(nodeFilter), page],
queryFn: () => getAccessLogs(nodeFilter), queryFn: () => getAccessLogs(page, nodeFilter),
}); });
const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]); const logs = useMemo(() => logsQuery.data?.items ?? [], [logsQuery.data]);
const hasMore = logsQuery.data?.has_more ?? false;
const pageSize = logsQuery.data?.page_size ?? 50;
const summary = useMemo(() => buildSummary(logs), [logs]); const summary = useMemo(() => buildSummary(logs), [logs]);
return ( return (
@@ -126,7 +129,10 @@ export function AccessLogsPage() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<PrimaryButton <PrimaryButton
type="button" type="button"
onClick={() => setNodeFilter(nodeFilterInput.trim())} onClick={() => {
setNodeFilter(nodeFilterInput.trim());
setPage(0);
}}
> >
筛选 筛选
</PrimaryButton> </PrimaryButton>
@@ -135,6 +141,7 @@ export function AccessLogsPage() {
onClick={() => { onClick={() => {
setNodeFilter(''); setNodeFilter('');
setNodeFilterInput(''); setNodeFilterInput('');
setPage(0);
}} }}
> >
清空 清空
@@ -209,6 +216,27 @@ export function AccessLogsPage() {
</table> </table>
</div> </div>
)} )}
<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)]">
第 {page + 1} 页,每页 {pageSize} 条。
</p>
<div className="flex gap-2">
<SecondaryButton
type="button"
disabled={page === 0 || logsQuery.isLoading}
onClick={() => setPage((value) => Math.max(value - 1, 0))}
>
上一页
</SecondaryButton>
<SecondaryButton
type="button"
disabled={!hasMore || logsQuery.isLoading}
onClick={() => setPage((value) => value + 1)}
>
下一页
</SecondaryButton>
</div>
</div>
</div> </div>
</AppCard> </AppCard>
</div> </div>
@@ -8,3 +8,10 @@ export interface AccessLogItem {
path: string; path: string;
status_code: number; status_code: number;
} }
export interface AccessLogList {
items: AccessLogItem[];
page: number;
page_size: number;
has_more: boolean;
}
@@ -733,10 +733,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
/> />
</div> </div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_1.1fr_0.8fr]"> <div className="grid gap-6 xl:grid-cols-3">
<AppCard <AppCard
title="系统画像" title="系统信息"
description="展示节点当前上报的主机事实信息,便于快速识别机器环境。"
> >
{observabilityQuery.isLoading ? ( {observabilityQuery.isLoading ? (
<LoadingState /> <LoadingState />