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

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,9 +1,10 @@
'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 { AppModal } from '@/components/ui/app-modal';
import { EmptyState } from '@/components/feedback/empty-state'; import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state'; import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message'; import { InlineMessage } from '@/components/feedback/inline-message';
@@ -11,16 +12,36 @@ import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; import {
import type { ApplyLogItem } from '@/features/apply-logs/types'; cleanupApplyLogs,
getApplyLogs,
} from '@/features/apply-logs/api/apply-logs';
import type {
ApplyLogCleanupPayload,
ApplyLogItem,
} from '@/features/apply-logs/types';
import { import {
PrimaryButton, PrimaryButton,
ResourceField,
ResourceInput, ResourceInput,
ResourceSelect,
SecondaryButton, SecondaryButton,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
const applyLogsQueryKey = (nodeId: string) => ['apply-logs', nodeId] as const; 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) { function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。'; return error instanceof Error ? error.message : '请求失败,请稍后重试。';
@@ -30,69 +51,92 @@ function getResultMeta(result: string) {
if (result === 'success') { if (result === 'success') {
return { label: '成功', variant: 'success' as const }; return { label: '成功', variant: 'success' as const };
} }
if (result === 'warning') { if (result === 'warning') {
return { label: '警告', variant: 'warning' as const }; return { label: '警告', variant: 'warning' as const };
} }
return { label: '失败', variant: 'danger' 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) { function truncateHash(value: string) {
if (!value) { if (!value) {
return '—'; return '—';
} }
return value.length > 12 ? `${value.slice(0, 12)}...` : value; 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() { export function ApplyLogsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [nodeFilterInput, setNodeFilterInput] = useState(''); const [nodeFilterInput, setNodeFilterInput] = useState('');
const [nodeFilter, setNodeFilter] = useState(''); const [nodeFilter, setNodeFilter] = useState('');
const [selectedLogId, setSelectedLogId] = useState<number | null>(null); const [pageNo, setPageNo] = useState(1);
const [feedback, setFeedback] = useState<string | null>(null); 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({ const logsQuery = useQuery({
queryKey: applyLogsQueryKey(nodeFilter), queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
queryFn: () => getApplyLogs(nodeFilter), queryFn: () =>
getApplyLogs({
node_id: nodeFilter || undefined,
pageNo,
pageSize,
}),
placeholderData: (previous) => previous,
}); });
const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]); const cleanupMutation = useMutation({
const summary = useMemo(() => buildSummary(logs), [logs]); 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(() => { useEffect(() => {
if (logs.length === 0) { if (rows.length === 0 && selectedLog) {
setSelectedLogId(null); setSelectedLog(null);
return; return;
} }
if (selectedLog && !rows.some((item) => item.id === selectedLog.id)) {
if (!logs.some((item) => item.id === selectedLogId)) { setSelectedLog(null);
setSelectedLogId(logs[0].id);
} }
}, [logs, selectedLogId]); }, [rows, selectedLog]);
const selectedLog = logs.find((item) => item.id === selectedLogId) ?? null;
const handleSearch = () => { const handleSearch = () => {
setFeedback(null); setFeedback(null);
setPageNo(1);
setNodeFilter(nodeFilterInput.trim()); setNodeFilter(nodeFilterInput.trim());
}; };
@@ -100,36 +144,67 @@ export function ApplyLogsPage() {
setFeedback(null); setFeedback(null);
setNodeFilter(''); setNodeFilter('');
setNodeFilterInput(''); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="应用记录" title="应用日志"
description="查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。" description="查看节点应用配置的成功、警告和失败记录,支持分页查询、详情弹窗和按保留天数清理。"
action={ action={
<Link <div className="flex flex-wrap gap-2">
href="/node" <SecondaryButton type="button" onClick={handleRefresh}>
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)]" 刷新
> </SecondaryButton>
返回节点页 <PrimaryButton
</Link> 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="info" message={feedback} /> : null} {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard <AppCard title="日志摘要" description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。">
title="记录摘要"
description="帮助快速识别失败趋势和受影响节点范围。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => ( {summary.map((item) => (
<div <div
key={item.label} key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4" 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 className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
{item.label} {item.label}
</p> </p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]"> <p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
@@ -140,54 +215,60 @@ export function ApplyLogsPage() {
</div> </div>
</AppCard> </AppCard>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]"> <AppCard
<AppCard title="过滤与列表"
title="过滤与列表" description="支持按 node_id 过滤,并按页查看应用结果。默认每页 20 条。"
description="默认展示全部记录,可按 node_id 快速筛选单节点应用结果。" >
action={ <div className="space-y-5">
<SecondaryButton <div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
type="button" <div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
onClick={() => <ResourceField label="Node ID">
void queryClient.invalidateQueries({ <ResourceInput
queryKey: applyLogsQueryKey(nodeFilter), value={nodeFilterInput}
}) onChange={(event) => setNodeFilterInput(event.target.value)}
} placeholder="输入 node_id 过滤应用日志"
> />
刷新 </ResourceField>
</SecondaryButton> <ResourceField label="每页条数">
} <ResourceSelect
> value={String(pageSize)}
<div className="space-y-5"> onChange={(event) => {
<div className="flex flex-col gap-3 lg:flex-row lg:items-center"> setPageSize(Number.parseInt(event.target.value, 10));
<ResourceInput setPageNo(1);
value={nodeFilterInput} }}
onChange={(event) => setNodeFilterInput(event.target.value)} >
placeholder="输入 node_id 过滤应用记录" {pageSizeOptions.map((option) => (
className="lg:max-w-md" <option key={option} value={option}>
/> {option} 条
<div className="flex flex-wrap gap-2"> </option>
<PrimaryButton type="button" onClick={handleSearch}> ))}
筛选 </ResourceSelect>
</PrimaryButton> </ResourceField>
<SecondaryButton type="button" onClick={handleReset}>
清空
</SecondaryButton>
</div>
</div> </div>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
筛选
</PrimaryButton>
<SecondaryButton type="button" onClick={handleReset}>
清空
</SecondaryButton>
</div>
</div>
{logsQuery.isLoading ? ( {logsQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : logsQuery.isError ? ( ) : logsQuery.isError ? (
<ErrorState <ErrorState
title="应用记录加载失败" title="应用日志加载失败"
description={getErrorMessage(logsQuery.error)} description={getErrorMessage(logsQuery.error)}
/> />
) : logs.length === 0 ? ( ) : rows.length === 0 ? (
<EmptyState <EmptyState
title="暂无应用记录" title="暂无应用日志"
description="当前筛选条件下没有可展示的应用结果。" 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">
<thead> <thead>
@@ -197,11 +278,12 @@ export function ApplyLogsPage() {
<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">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> <th className="px-3 py-3 font-medium">消息</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--border-default)]"> <tbody className="divide-y divide-[var(--border-default)]">
{logs.map((log) => { {rows.map((log) => {
const resultMeta = getResultMeta(log.result); const resultMeta = getResultMeta(log.result);
return ( return (
<tr key={log.id} className="align-top"> <tr key={log.id} className="align-top">
@@ -231,16 +313,16 @@ export function ApplyLogsPage() {
</p> </p>
</div> </div>
</td> </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"> <td className="px-3 py-4">
<SecondaryButton <SecondaryButton
type="button" type="button"
onClick={() => {
setSelectedLogId(log.id);
setFeedback(
`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`,
);
}}
className="px-3 py-2 text-xs" className="px-3 py-2 text-xs"
onClick={() => setSelectedLog(log)}
> >
查看详情 查看详情
</SecondaryButton> </SecondaryButton>
@@ -251,99 +333,176 @@ export function ApplyLogsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div>
</AppCard>
<AppCard <div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between">
title="记录详情" <p className="text-sm text-[var(--foreground-secondary)]">
description="展示所选应用记录的完整结果与错误信息。" 第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
>
{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> </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>
</div> </>
) : (
<EmptyState
title="未选择记录"
description="请先从左侧列表中选择一条应用记录查看详情。"
/>
)} )}
</AppCard> </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> </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}