mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
[功能] 添加应用日志分页查询和清理功能,优化日志管理逻辑
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user