[功能] 添加应用日志分页查询和清理功能,优化日志管理逻辑

This commit is contained in:
ryan
2026-03-18 14:34:25 +08:00
parent 0ab0145f8d
commit 4433ab5af4
9 changed files with 790 additions and 370 deletions
+37 -1
View File
@@ -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
}
+41 -5
View File
@@ -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
}
+1
View File
@@ -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())
+42 -4
View File
@@ -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{
+100 -2
View File
@@ -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) {
@@ -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<ApplyLogItem[]>(`/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<ApplyLogList>(`/apply-logs/${suffix}`);
}
export function cleanupApplyLogs(payload: ApplyLogCleanupPayload) {
return apiRequest<ApplyLogCleanupResult>('/apply-logs/cleanup', {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -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<number | null>(null);
const [feedback, setFeedback] = useState<string | null>(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 (
<div className="space-y-6">
<PageHeader
title="应用记录"
description="查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。"
action={
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回节点页
</Link>
}
/>
{feedback ? <InlineMessage tone="info" message={feedback} /> : null}
<AppCard
title="记录摘要"
description="帮助快速识别失败趋势和受影响节点范围。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{item.label}
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{item.value}
</p>
</div>
))}
</div>
</AppCard>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<AppCard
title="过滤与列表"
description="默认展示全部记录,可按 node_id 快速筛选单节点应用结果。"
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: applyLogsQueryKey(nodeFilter),
})
}
>
刷新
</SecondaryButton>
}
>
<div className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<ResourceInput
value={nodeFilterInput}
onChange={(event) => setNodeFilterInput(event.target.value)}
placeholder="输入 node_id 过滤应用记录"
className="lg:max-w-md"
/>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
筛选
</PrimaryButton>
<SecondaryButton type="button" onClick={handleReset}>
清空
</SecondaryButton>
</div>
</div>
{logsQuery.isLoading ? (
<LoadingState />
) : logsQuery.isError ? (
<ErrorState
title="应用记录加载失败"
description={getErrorMessage(logsQuery.error)}
/>
) : logs.length === 0 ? (
<EmptyState
title="暂无应用记录"
description="当前筛选条件下没有可展示的应用结果。"
/>
) : (
<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">Node ID</th>
<th className="px-3 py-3 font-medium">版本</th>
<th className="px-3 py-3 font-medium">结果</th>
<th className="px-3 py-3 font-medium">Checksum</th>
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">详情</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{logs.map((log) => {
const resultMeta = getResultMeta(log.result);
return (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{log.node_id}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{log.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={resultMeta.label}
variant={resultMeta.variant}
/>
</td>
<td
className="px-3 py-4 text-[var(--foreground-secondary)]"
title={log.checksum}
>
{truncateHash(log.checksum)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="space-y-1">
<p>{formatDateTime(log.created_at)}</p>
<p className="text-xs">
{formatRelativeTime(log.created_at)}
</p>
</div>
</td>
<td className="px-3 py-4">
<SecondaryButton
type="button"
onClick={() => {
setSelectedLogId(log.id);
setFeedback(
`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`,
);
}}
className="px-3 py-2 text-xs"
>
查看详情
</SecondaryButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</AppCard>
<AppCard
title="记录详情"
description="展示所选应用记录的完整结果与错误信息。"
>
{selectedLog ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<StatusBadge {...getResultMeta(selectedLog.result)} />
<StatusBadge
label={`Node:${selectedLog.node_id}`}
variant="info"
/>
<StatusBadge
label={`版本:${selectedLog.version}`}
variant="warning"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
创建时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(selectedLog.created_at)}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
相对时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatRelativeTime(selectedLog.created_at)}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
目标 Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.checksum || '无'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
支持文件数
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{selectedLog.support_file_count}
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
主配置摘要
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.main_config_checksum || '无'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
路由配置摘要
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.route_config_checksum || '无'}
</p>
</div>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
应用信息
</p>
<p className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
{selectedLog.message || '无附加信息'}
</p>
</div>
</div>
) : (
<EmptyState
title="未选择记录"
description="请先从左侧列表中选择一条应用记录查看详情。"
/>
)}
</AppCard>
</div>
</div>
);
}
'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<ApplyLogItem | null>(null);
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
const [cleanupMode, setCleanupMode] = useState<'all' | 'custom'>('custom');
const [customRetentionDays, setCustomRetentionDays] = useState('30');
const [feedback, setFeedback] = useState<FeedbackState | null>(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 (
<div className="space-y-6">
<PageHeader
title="应用日志"
description="查看节点应用配置的成功、警告和失败记录,支持分页查询、详情弹窗和按保留天数清理。"
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton type="button" onClick={handleRefresh}>
刷新
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setFeedback(null);
setCleanupModalOpen(true);
}}
>
删除日志
</PrimaryButton>
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回节点
</Link>
</div>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard title="日志摘要" description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
{item.label}
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{item.value}
</p>
</div>
))}
</div>
</AppCard>
<AppCard
title="过滤与列表"
description="支持按 node_id 过滤,并按页查看应用结果。默认每页 20 条。"
>
<div className="space-y-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
<ResourceField label="Node ID">
<ResourceInput
value={nodeFilterInput}
onChange={(event) => setNodeFilterInput(event.target.value)}
placeholder="输入 node_id 过滤应用日志"
/>
</ResourceField>
<ResourceField label="每页条数">
<ResourceSelect
value={String(pageSize)}
onChange={(event) => {
setPageSize(Number.parseInt(event.target.value, 10));
setPageNo(1);
}}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option} 条
</option>
))}
</ResourceSelect>
</ResourceField>
</div>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
筛选
</PrimaryButton>
<SecondaryButton type="button" onClick={handleReset}>
清空
</SecondaryButton>
</div>
</div>
{logsQuery.isLoading ? (
<LoadingState />
) : logsQuery.isError ? (
<ErrorState
title="应用日志加载失败"
description={getErrorMessage(logsQuery.error)}
/>
) : rows.length === 0 ? (
<EmptyState
title="暂无应用日志"
description="当前筛选条件下没有可展示的应用记录。"
/>
) : (
<>
<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">Node ID</th>
<th className="px-3 py-3 font-medium">版本</th>
<th className="px-3 py-3 font-medium">结果</th>
<th className="px-3 py-3 font-medium">Checksum</th>
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">消息</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{rows.map((log) => {
const resultMeta = getResultMeta(log.result);
return (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{log.node_id}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{log.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={resultMeta.label}
variant={resultMeta.variant}
/>
</td>
<td
className="px-3 py-4 text-[var(--foreground-secondary)]"
title={log.checksum}
>
{truncateHash(log.checksum)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="space-y-1">
<p>{formatDateTime(log.created_at)}</p>
<p className="text-xs">
{formatRelativeTime(log.created_at)}
</p>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="max-w-72 break-words whitespace-pre-wrap">
{log.message || '—'}
</div>
</td>
<td className="px-3 py-4">
<SecondaryButton
type="button"
className="px-3 py-2 text-xs"
onClick={() => setSelectedLog(log)}
>
查看详情
</SecondaryButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-[var(--foreground-secondary)]">
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
</p>
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
disabled={current <= 1}
onClick={() => setPageNo((previous) => Math.max(1, previous - 1))}
>
上一页
</SecondaryButton>
<SecondaryButton
type="button"
disabled={totalPage === 0 || current >= totalPage}
onClick={() =>
setPageNo((previous) =>
totalPage > 0 ? Math.min(totalPage, previous + 1) : previous,
)
}
>
下一页
</SecondaryButton>
</div>
</div>
</>
)}
</div>
</AppCard>
<AppModal
isOpen={selectedLog !== null}
title="应用日志详情"
description="查看单条应用日志的完整结果、消息和校验信息。"
size="lg"
onClose={() => setSelectedLog(null)}
>
{selectedLog ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<StatusBadge {...getResultMeta(selectedLog.result)} />
<StatusBadge label={`Node:${selectedLog.node_id}`} variant="info" />
<StatusBadge label={`版本:${selectedLog.version}`} variant="warning" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<MetricCard label="创建时间" value={formatDateTime(selectedLog.created_at)} />
<MetricCard label="相对时间" value={formatRelativeTime(selectedLog.created_at)} />
<MetricCard label="目标 Checksum" value={selectedLog.checksum || '—'} breakAll />
<MetricCard
label="支持文件数"
value={String(selectedLog.support_file_count)}
/>
<MetricCard
label="主配置摘要"
value={selectedLog.main_config_checksum || '—'}
breakAll
/>
<MetricCard
label="路由配置摘要"
value={selectedLog.route_config_checksum || '—'}
breakAll
/>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
消息
</p>
<pre className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--foreground-primary)]">
{selectedLog.message || '—'}
</pre>
</div>
</div>
) : null}
</AppModal>
<AppModal
isOpen={isCleanupModalOpen}
title="删除应用日志"
description="可以选择删除全部应用日志,或仅保留最近自定义天数内的记录。"
onClose={() => setCleanupModalOpen(false)}
footer={
<div className="flex flex-wrap justify-end gap-2">
<SecondaryButton type="button" onClick={() => setCleanupModalOpen(false)}>
取消
</SecondaryButton>
<PrimaryButton
type="button"
disabled={cleanupMutation.isPending}
onClick={handleCleanupConfirm}
>
{cleanupMutation.isPending ? '处理中...' : '确认删除'}
</PrimaryButton>
</div>
}
>
<div className="space-y-5">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setCleanupMode('all')}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupMode === 'all'
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
全部删除
</button>
<button
type="button"
onClick={() => setCleanupMode('custom')}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupMode === 'custom'
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
保留自定义天数
</button>
</div>
{cleanupMode === 'custom' ? (
<ResourceField label="保留天数" hint="当前支持 1 到 3650 天。">
<ResourceInput
value={customRetentionDays}
onChange={(event) => setCustomRetentionDays(event.target.value)}
type="number"
min={1}
max={3650}
placeholder="输入保留天数"
/>
</ResourceField>
) : null}
{cleanupMutation.isError ? (
<ErrorState
title="删除应用日志失败"
description={getErrorMessage(cleanupMutation.error)}
/>
) : null}
</div>
</AppModal>
</div>
);
}
function MetricCard({
label,
value,
breakAll = false,
}: {
label: string;
value: string;
breakAll?: boolean;
}) {
return (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
{label}
</p>
<p
className={`mt-2 text-sm text-[var(--foreground-primary)] ${
breakAll ? 'break-all' : ''
}`}
>
{value}
</p>
</div>
);
}
@@ -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;
}
@@ -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 }) {
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{applyLogs.slice(0, 10).map((log) => (
{applyLogs.map((log) => (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-primary)]">
{log.version}