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

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 ( import (
"openflare/model" "openflare/model"
"openflare/service" "openflare/service"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -144,10 +145,45 @@ func GetNodes(c *gin.Context) {
// @Success 200 {object} map[string]interface{} // @Success 200 {object} map[string]interface{}
// @Router /api/apply-logs/ [get] // @Router /api/apply-logs/ [get]
func GetApplyLogs(c *gin.Context) { 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 { if err != nil {
respondFailure(c, err.Error()) respondFailure(c, err.Error())
return return
} }
respondSuccess(c, logs) 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 package model
import "time" import (
"time"
"gorm.io/gorm"
)
type ApplyLogQuery struct {
NodeID string
PageNo int
PageSize int
}
type ApplyLog struct { type ApplyLog struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
@@ -15,13 +25,29 @@ type ApplyLog struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
func ListApplyLogs(nodeID string) (logs []*ApplyLog, err error) { func ListApplyLogs(query ApplyLogQuery) (logs []*ApplyLog, err error) {
query := DB.Order("id desc") 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 != "" { if nodeID != "" {
query = query.Where("node_id = ?", nodeID) query = query.Where("node_id = ?", nodeID)
} }
err = query.Find(&logs).Error err = query.Count(&total).Error
return logs, err return total, err
} }
func GetLatestApplyLog(nodeID string) (*ApplyLog, error) { func GetLatestApplyLog(nodeID string) (*ApplyLog, error) {
@@ -49,3 +75,13 @@ func GetLatestApplyLogsByNodeIDs(nodeIDs []string) (map[string]*ApplyLog, error)
} }
return result, nil 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.Use(middleware.AdminAuth())
{ {
applyLogRoute.GET("/", controller.GetApplyLogs) applyLogRoute.GET("/", controller.GetApplyLogs)
applyLogRoute.POST("/cleanup", controller.CleanupApplyLogs)
} }
accessLogRoute := apiRouter.Group("/access-logs") accessLogRoute := apiRouter.Group("/access-logs")
accessLogRoute.Use(middleware.AdminAuth()) 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") 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) logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID+"&pageNo=1&pageSize=1", nil)
var logs []model.ApplyLog var logs service.ApplyLogListResult
decodeResponseData(t, logsResp, &logs) decodeResponseData(t, logsResp, &logs)
if len(logs) != 2 { if logs.Current != 1 || logs.Total != 2 || logs.TotalPage != 2 {
t.Fatalf("expected 2 apply logs, got %d", len(logs)) 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{ 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"` 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 { type AgentConfigResponse struct {
Version string `json:"version"` Version string `json:"version"`
Checksum string `json:"checksum"` Checksum string `json:"checksum"`
@@ -314,8 +339,81 @@ func ListNodeViews() ([]*NodeView, error) {
return views, nil return views, nil
} }
func ListApplyLogs(nodeID string) ([]*model.ApplyLog, error) { const (
return model.ListApplyLogs(strings.TrimSpace(nodeID)) 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) { func upsertNode(payload AgentNodePayload) (*model.Node, error) {
@@ -1,9 +1,31 @@
import { apiRequest } from '@/lib/api/client'; 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) { export function getApplyLogs(query: ApplyLogListQuery = {}) {
const normalizedNodeId = nodeId?.trim(); const params = new URLSearchParams();
const query = normalizedNodeId ? `?node_id=${encodeURIComponent(normalizedNodeId)}` : ''; const normalizedNodeId = query.node_id?.trim();
return apiRequest<ApplyLogItem[]>(`/apply-logs/${query}`); 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'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { EmptyState } from '@/components/feedback/empty-state'; import { AppModal } from '@/components/ui/app-modal';
import { ErrorState } from '@/components/feedback/error-state'; import { EmptyState } from '@/components/feedback/empty-state';
import { InlineMessage } from '@/components/feedback/inline-message'; import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state'; import { InlineMessage } from '@/components/feedback/inline-message';
import { PageHeader } from '@/components/layout/page-header'; import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card'; import { PageHeader } from '@/components/layout/page-header';
import { StatusBadge } from '@/components/ui/status-badge'; import { AppCard } from '@/components/ui/app-card';
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; import { StatusBadge } from '@/components/ui/status-badge';
import type { ApplyLogItem } from '@/features/apply-logs/types'; import {
import { cleanupApplyLogs,
PrimaryButton, getApplyLogs,
ResourceInput, } from '@/features/apply-logs/api/apply-logs';
SecondaryButton, import type {
} from '@/features/shared/components/resource-primitives'; ApplyLogCleanupPayload,
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; ApplyLogItem,
} from '@/features/apply-logs/types';
const applyLogsQueryKey = (nodeId: string) => ['apply-logs', nodeId] as const; import {
PrimaryButton,
function getErrorMessage(error: unknown) { ResourceField,
return error instanceof Error ? error.message : '请求失败,请稍后重试。'; ResourceInput,
} ResourceSelect,
SecondaryButton,
function getResultMeta(result: string) { } from '@/features/shared/components/resource-primitives';
if (result === 'success') { import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
return { label: '成功', variant: 'success' as const };
} const applyLogsQueryKey = (
nodeId: string,
if (result === 'warning') { pageNo: number,
return { label: '警告', variant: 'warning' as const }; pageSize: number,
} ) => ['apply-logs', nodeId, pageNo, pageSize] as const;
return { label: '失败', variant: 'danger' as const }; const pageSizeOptions = [20, 50, 100];
} const emptyApplyLogRows: ApplyLogItem[] = [];
function buildSummary(logs: ApplyLogItem[]) { type FeedbackState = {
const nodeIds = new Set(logs.map((item) => item.node_id)); tone: 'info' | 'success' | 'danger';
message: string;
return [ };
{ label: '记录总数', value: logs.length },
{ function getErrorMessage(error: unknown) {
label: '成功', return error instanceof Error ? error.message : '请求失败,请稍后重试。';
value: logs.filter((item) => item.result === 'success').length, }
},
{ function getResultMeta(result: string) {
label: '失败', if (result === 'success') {
value: logs.filter((item) => item.result !== 'success').length, return { label: '成功', variant: 'success' as const };
}, }
{ label: '节点数', value: nodeIds.size }, if (result === 'warning') {
]; return { label: '警告', variant: 'warning' as const };
} }
return { label: '失败', variant: 'danger' as const };
function truncateHash(value: string) { }
if (!value) {
return '—'; function truncateHash(value: string) {
} if (!value) {
return '—';
return value.length > 12 ? `${value.slice(0, 12)}...` : value; }
} return value.length > 12 ? `${value.slice(0, 12)}...` : value;
}
export function ApplyLogsPage() {
const queryClient = useQueryClient(); function buildSummary(rows: ApplyLogItem[], total: number, current: number, totalPage: number) {
const [nodeFilterInput, setNodeFilterInput] = useState(''); const nodeIds = new Set(rows.map((item) => item.node_id));
const [nodeFilter, setNodeFilter] = useState(''); return [
const [selectedLogId, setSelectedLogId] = useState<number | null>(null); { label: '总记录数', value: total },
const [feedback, setFeedback] = useState<string | null>(null); { label: '当前页', value: current },
{ label: '总页数', value: totalPage },
const logsQuery = useQuery({ { label: '当前页节点数', value: nodeIds.size },
queryKey: applyLogsQueryKey(nodeFilter), ];
queryFn: () => getApplyLogs(nodeFilter), }
});
export function ApplyLogsPage() {
const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]); const queryClient = useQueryClient();
const summary = useMemo(() => buildSummary(logs), [logs]); const [nodeFilterInput, setNodeFilterInput] = useState('');
const [nodeFilter, setNodeFilter] = useState('');
useEffect(() => { const [pageNo, setPageNo] = useState(1);
if (logs.length === 0) { const [pageSize, setPageSize] = useState(20);
setSelectedLogId(null); const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null);
return; const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
} const [cleanupMode, setCleanupMode] = useState<'all' | 'custom'>('custom');
const [customRetentionDays, setCustomRetentionDays] = useState('30');
if (!logs.some((item) => item.id === selectedLogId)) { const [feedback, setFeedback] = useState<FeedbackState | null>(null);
setSelectedLogId(logs[0].id);
} const logsQuery = useQuery({
}, [logs, selectedLogId]); queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
queryFn: () =>
const selectedLog = logs.find((item) => item.id === selectedLogId) ?? null; getApplyLogs({
node_id: nodeFilter || undefined,
const handleSearch = () => { pageNo,
setFeedback(null); pageSize,
setNodeFilter(nodeFilterInput.trim()); }),
}; placeholderData: (previous) => previous,
});
const handleReset = () => {
setFeedback(null); const cleanupMutation = useMutation({
setNodeFilter(''); mutationFn: (payload: ApplyLogCleanupPayload) => cleanupApplyLogs(payload),
setNodeFilterInput(''); onSuccess: async (result) => {
}; setCleanupModalOpen(false);
setPageNo(1);
return ( setFeedback({
<div className="space-y-6"> tone: 'success',
<PageHeader message: result.delete_all
title="应用记录" ? `已删除全部应用日志,共 ${result.deleted_count} 条。`
description="查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。" : `已清理保留期之外的应用日志,共 ${result.deleted_count} 条。`,
action={ });
<Link await queryClient.invalidateQueries({ queryKey: ['apply-logs'] });
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)]" onError: (error) => {
> setFeedback({ tone: 'danger', message: getErrorMessage(error) });
返回节点页 },
</Link> });
}
/> const rows = logsQuery.data?.rows ?? emptyApplyLogRows;
const current = logsQuery.data?.current ?? pageNo;
{feedback ? <InlineMessage tone="info" message={feedback} /> : null} const total = logsQuery.data?.total ?? 0;
const totalPage = logsQuery.data?.totalPage ?? 0;
<AppCard const summary = useMemo(
title="记录摘要" () => buildSummary(rows, total, current, totalPage),
description="帮助快速识别失败趋势和受影响节点范围。" [rows, total, current, totalPage],
> );
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => ( useEffect(() => {
<div if (rows.length === 0 && selectedLog) {
key={item.label} setSelectedLog(null);
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4" return;
> }
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> if (selectedLog && !rows.some((item) => item.id === selectedLog.id)) {
{item.label} setSelectedLog(null);
</p> }
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]"> }, [rows, selectedLog]);
{item.value}
</p> const handleSearch = () => {
</div> setFeedback(null);
))} setPageNo(1);
</div> setNodeFilter(nodeFilterInput.trim());
</AppCard> };
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]"> const handleReset = () => {
<AppCard setFeedback(null);
title="过滤与列表" setNodeFilter('');
description="默认展示全部记录,可按 node_id 快速筛选单节点应用结果。" setNodeFilterInput('');
action={ setPageNo(1);
<SecondaryButton };
type="button"
onClick={() => const handleRefresh = async () => {
void queryClient.invalidateQueries({ setFeedback(null);
queryKey: applyLogsQueryKey(nodeFilter), await queryClient.invalidateQueries({
}) queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
} });
> };
刷新
</SecondaryButton> const handleCleanupConfirm = () => {
} const payload: ApplyLogCleanupPayload =
> cleanupMode === 'all'
<div className="space-y-5"> ? { delete_all: true }
<div className="flex flex-col gap-3 lg:flex-row lg:items-center"> : {
<ResourceInput retention_days: Number.parseInt(customRetentionDays, 10),
value={nodeFilterInput} };
onChange={(event) => setNodeFilterInput(event.target.value)} cleanupMutation.mutate(payload);
placeholder="输入 node_id 过滤应用记录" };
className="lg:max-w-md"
/> return (
<div className="flex flex-wrap gap-2"> <div className="space-y-6">
<PrimaryButton type="button" onClick={handleSearch}> <PageHeader
筛选 title="应用日志"
</PrimaryButton> description="查看节点应用配置的成功、警告和失败记录,支持分页查询、详情弹窗和按保留天数清理。"
<SecondaryButton type="button" onClick={handleReset}> action={
清空 <div className="flex flex-wrap gap-2">
</SecondaryButton> <SecondaryButton type="button" onClick={handleRefresh}>
</div> 刷新
</div> </SecondaryButton>
<PrimaryButton
{logsQuery.isLoading ? ( type="button"
<LoadingState /> onClick={() => {
) : logsQuery.isError ? ( setFeedback(null);
<ErrorState setCleanupModalOpen(true);
title="应用记录加载失败" }}
description={getErrorMessage(logsQuery.error)} >
/> 删除日志
) : logs.length === 0 ? ( </PrimaryButton>
<EmptyState <Link
title="暂无应用记录" href="/node"
description="当前筛选条件下没有可展示的应用结果。" 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)]"
/> >
) : ( 返回节点
<div className="overflow-x-auto"> </Link>
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm"> </div>
<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> {feedback ? (
<th className="px-3 py-3 font-medium">结果</th> <InlineMessage tone={feedback.tone} message={feedback.message} />
<th className="px-3 py-3 font-medium">Checksum</th> ) : null}
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">详情</th> <AppCard title="日志摘要" description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。">
</tr> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
</thead> {summary.map((item) => (
<tbody className="divide-y divide-[var(--border-default)]"> <div
{logs.map((log) => { key={item.label}
const resultMeta = getResultMeta(log.result); className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
return ( >
<tr key={log.id} className="align-top"> <p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]"> {item.label}
{log.node_id} </p>
</td> <p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
<td className="px-3 py-4 text-[var(--foreground-secondary)]"> {item.value}
{log.version} </p>
</td> </div>
<td className="px-3 py-4"> ))}
<StatusBadge </div>
label={resultMeta.label} </AppCard>
variant={resultMeta.variant}
/> <AppCard
</td> title="过滤与列表"
<td description="支持按 node_id 过滤,并按页查看应用结果。默认每页 20 条。"
className="px-3 py-4 text-[var(--foreground-secondary)]" >
title={log.checksum} <div className="space-y-5">
> <div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
{truncateHash(log.checksum)} <div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
</td> <ResourceField label="Node ID">
<td className="px-3 py-4 text-[var(--foreground-secondary)]"> <ResourceInput
<div className="space-y-1"> value={nodeFilterInput}
<p>{formatDateTime(log.created_at)}</p> onChange={(event) => setNodeFilterInput(event.target.value)}
<p className="text-xs"> placeholder="输入 node_id 过滤应用日志"
{formatRelativeTime(log.created_at)} />
</p> </ResourceField>
</div> <ResourceField label="每页条数">
</td> <ResourceSelect
<td className="px-3 py-4"> value={String(pageSize)}
<SecondaryButton onChange={(event) => {
type="button" setPageSize(Number.parseInt(event.target.value, 10));
onClick={() => { setPageNo(1);
setSelectedLogId(log.id); }}
setFeedback( >
`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`, {pageSizeOptions.map((option) => (
); <option key={option} value={option}>
}} {option} 条
className="px-3 py-2 text-xs" </option>
> ))}
查看详情 </ResourceSelect>
</SecondaryButton> </ResourceField>
</td> </div>
</tr> <div className="flex flex-wrap gap-2">
); <PrimaryButton type="button" onClick={handleSearch}>
})} 筛选
</tbody> </PrimaryButton>
</table> <SecondaryButton type="button" onClick={handleReset}>
</div> 清空
)} </SecondaryButton>
</div> </div>
</AppCard> </div>
<AppCard {logsQuery.isLoading ? (
title="记录详情" <LoadingState />
description="展示所选应用记录的完整结果与错误信息。" ) : logsQuery.isError ? (
> <ErrorState
{selectedLog ? ( title="应用日志加载失败"
<div className="space-y-4"> description={getErrorMessage(logsQuery.error)}
<div className="flex flex-wrap gap-2"> />
<StatusBadge {...getResultMeta(selectedLog.result)} /> ) : rows.length === 0 ? (
<StatusBadge <EmptyState
label={`Node:${selectedLog.node_id}`} title="暂无应用日志"
variant="info" description="当前筛选条件下没有可展示的应用记录。"
/> />
<StatusBadge ) : (
label={`版本:${selectedLog.version}`} <>
variant="warning" <div className="overflow-x-auto">
/> <table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
</div> <thead>
<tr className="text-[var(--foreground-secondary)]">
<div className="grid gap-4 sm:grid-cols-2"> <th className="px-3 py-3 font-medium">Node ID</th>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <th className="px-3 py-3 font-medium">版本</th>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> <th className="px-3 py-3 font-medium">结果</th>
创建时间 <th className="px-3 py-3 font-medium">Checksum</th>
</p> <th className="px-3 py-3 font-medium">时间</th>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <th className="px-3 py-3 font-medium">消息</th>
{formatDateTime(selectedLog.created_at)} <th className="px-3 py-3 font-medium">操作</th>
</p> </tr>
</div> </thead>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <tbody className="divide-y divide-[var(--border-default)]">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> {rows.map((log) => {
相对时间 const resultMeta = getResultMeta(log.result);
</p> return (
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <tr key={log.id} className="align-top">
{formatRelativeTime(selectedLog.created_at)} <td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
</p> {log.node_id}
</div> </td>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <td className="px-3 py-4 text-[var(--foreground-secondary)]">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> {log.version}
目标 Checksum </td>
</p> <td className="px-3 py-4">
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]"> <StatusBadge
{selectedLog.checksum || '无'} label={resultMeta.label}
</p> variant={resultMeta.variant}
</div> />
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> </td>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> <td
支持文件数 className="px-3 py-4 text-[var(--foreground-secondary)]"
</p> title={log.checksum}
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> >
{selectedLog.support_file_count} {truncateHash(log.checksum)}
</p> </td>
</div> <td className="px-3 py-4 text-[var(--foreground-secondary)]">
</div> <div className="space-y-1">
<p>{formatDateTime(log.created_at)}</p>
<div className="grid gap-4 sm:grid-cols-2"> <p className="text-xs">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> {formatRelativeTime(log.created_at)}
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> </p>
主配置摘要 </div>
</p> </td>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]"> <td className="px-3 py-4 text-[var(--foreground-secondary)]">
{selectedLog.main_config_checksum || '无'} <div className="max-w-72 break-words whitespace-pre-wrap">
</p> {log.message || '—'}
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> </td>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> <td className="px-3 py-4">
路由配置摘要 <SecondaryButton
</p> type="button"
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]"> className="px-3 py-2 text-xs"
{selectedLog.route_config_checksum || '无'} onClick={() => setSelectedLog(log)}
</p> >
</div> 查看详情
</div> </SecondaryButton>
</td>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> </tr>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> );
应用信息 })}
</p> </tbody>
<p className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]"> </table>
{selectedLog.message || '无附加信息'} </div>
</p>
</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">
</div> <p className="text-sm text-[var(--foreground-secondary)]">
) : ( 第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
<EmptyState </p>
title="未选择记录" <div className="flex flex-wrap gap-2">
description="请先从左侧列表中选择一条应用记录查看详情。" <SecondaryButton
/> type="button"
)} disabled={current <= 1}
</AppCard> onClick={() => setPageNo((previous) => Math.max(1, previous - 1))}
</div> >
</div> 上一页
); </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; support_file_count: number;
created_at: string; 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]); }, [nodeId, nodesQuery.data]);
const applyLogsQuery = useQuery({ const applyLogsQuery = useQuery({
queryKey: ['apply-logs', node?.node_id ?? ''], queryKey: ['apply-logs', node?.node_id ?? '', 1, 10],
queryFn: () => getApplyLogs(node?.node_id), queryFn: () =>
getApplyLogs({
node_id: node?.node_id,
pageNo: 1,
pageSize: 10,
}),
enabled: Boolean(node?.node_id), enabled: Boolean(node?.node_id),
refetchInterval: 5000, refetchInterval: 5000,
}); });
@@ -525,7 +530,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
selectedReleaseChannel === 'preview' selectedReleaseChannel === 'preview'
? previewAgentReleaseQuery.isFetching ? previewAgentReleaseQuery.isFetching
: stableAgentReleaseQuery.isFetching; : stableAgentReleaseQuery.isFetching;
const applyLogs = applyLogsQuery.data ?? []; const applyLogs = applyLogsQuery.data?.rows ?? [];
const dominantStatusCode = statusCodeDistribution[0] ?? null; const dominantStatusCode = statusCodeDistribution[0] ?? null;
const dominantDomain = topDomains[0] ?? null; const dominantDomain = topDomains[0] ?? null;
const topSourceCountry = const topSourceCountry =
@@ -1623,7 +1628,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--border-default)]"> <tbody className="divide-y divide-[var(--border-default)]">
{applyLogs.slice(0, 10).map((log) => ( {applyLogs.map((log) => (
<tr key={log.id} className="align-top"> <tr key={log.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-primary)]"> <td className="px-3 py-4 text-[var(--foreground-primary)]">
{log.version} {log.version}