774 lines
33 KiB
Go
774 lines
33 KiB
Go
package monitor
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// APIMonitorController API 监控控制器
|
|
type APIMonitorController struct{}
|
|
|
|
// NewAPIMonitorController 创建 API 监控控制器
|
|
func NewAPIMonitorController() *APIMonitorController {
|
|
return &APIMonitorController{}
|
|
}
|
|
|
|
// GetAPIStats 获取指定 API 的统计信息
|
|
func (amc *APIMonitorController) GetAPIStats(c *gin.Context) {
|
|
endpoint := c.Query("endpoint")
|
|
if endpoint == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "缺少 endpoint 参数",
|
|
})
|
|
return
|
|
}
|
|
|
|
apiMonitorsMu.RLock()
|
|
monitor, exists := apiMonitors[endpoint]
|
|
apiMonitorsMu.RUnlock()
|
|
|
|
if !exists {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": "该接口暂无监控数据",
|
|
})
|
|
return
|
|
}
|
|
|
|
stats := monitor.GetStats()
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "success",
|
|
"data": stats,
|
|
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
|
})
|
|
}
|
|
|
|
// GetAllAPIStats 获取所有 API 的统计信息
|
|
func (amc *APIMonitorController) GetAllAPIStats(c *gin.Context) {
|
|
apiMonitorsMu.RLock()
|
|
defer apiMonitorsMu.RUnlock()
|
|
|
|
allStats := make([]*APIStats, 0, len(apiMonitors))
|
|
endpoints := make([]string, 0, len(apiMonitors))
|
|
|
|
// 收集所有 endpoint
|
|
for endpoint := range apiMonitors {
|
|
endpoints = append(endpoints, endpoint)
|
|
}
|
|
|
|
// 按 endpoint 字母顺序排序
|
|
sort.Strings(endpoints)
|
|
|
|
// 按排序后的顺序获取统计信息
|
|
for _, endpoint := range endpoints {
|
|
allStats = append(allStats, apiMonitors[endpoint].GetStats())
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "success",
|
|
"data": allStats,
|
|
"count": len(allStats),
|
|
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
|
})
|
|
}
|
|
|
|
// GetAPICallDetail 获取指定 API 调用的详细信息
|
|
func (amc *APIMonitorController) GetAPICallDetail(c *gin.Context) {
|
|
endpoint := c.Query("endpoint")
|
|
callIDStr := c.Query("call_id")
|
|
callType := c.Query("type") // es or redis
|
|
|
|
if endpoint == "" || callIDStr == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "缺少 endpoint 或 call_id 参数",
|
|
})
|
|
return
|
|
}
|
|
|
|
callID, err := strconv.ParseInt(callIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "无效的 call_id",
|
|
})
|
|
return
|
|
}
|
|
|
|
apiMonitorsMu.RLock()
|
|
monitor, exists := apiMonitors[endpoint]
|
|
apiMonitorsMu.RUnlock()
|
|
|
|
if !exists {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": "该接口暂无监控数据",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 查找对应的调用记录
|
|
var foundRecord *APICallRecord
|
|
var recordType string
|
|
|
|
if callType == "es" {
|
|
foundRecord = monitor.GetESCallByID(callID)
|
|
if foundRecord != nil {
|
|
recordType = "ES"
|
|
}
|
|
} else if callType == "redis" {
|
|
foundRecord = monitor.GetRedisCallByID(callID)
|
|
if foundRecord != nil {
|
|
recordType = "Redis"
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "type 参数必须是 es 或 redis",
|
|
})
|
|
return
|
|
}
|
|
|
|
if foundRecord == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": "未找到对应的调用记录",
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "success",
|
|
"data": gin.H{
|
|
"id": foundRecord.ID,
|
|
"type": recordType,
|
|
"timestamp": foundRecord.Timestamp.Format("2006-01-02 15:04:05"),
|
|
"duration_ms": foundRecord.Duration,
|
|
"success": foundRecord.Success,
|
|
"error": foundRecord.Error,
|
|
"operation": foundRecord.Operation,
|
|
"key_or_index": foundRecord.KeyOrIndex,
|
|
"query": foundRecord.Query,
|
|
"request": foundRecord.Request,
|
|
"response": foundRecord.Response,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetAPIESCalls 获取指定 API 的 ES 调用记录
|
|
func (amc *APIMonitorController) GetAPIESCalls(c *gin.Context) {
|
|
endpoint := c.Query("endpoint")
|
|
if endpoint == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "缺少 endpoint 参数",
|
|
})
|
|
return
|
|
}
|
|
|
|
pageStr := c.DefaultQuery("page", "1")
|
|
pageSizeStr := c.DefaultQuery("page_size", "50")
|
|
|
|
page, err := strconv.Atoi(pageStr)
|
|
if err != nil || page <= 0 {
|
|
page = 1
|
|
}
|
|
|
|
pageSize, err := strconv.Atoi(pageSizeStr)
|
|
if err != nil || pageSize <= 0 {
|
|
pageSize = 50
|
|
}
|
|
if pageSize > 500 {
|
|
pageSize = 500
|
|
}
|
|
|
|
apiMonitorsMu.RLock()
|
|
monitor, exists := apiMonitors[endpoint]
|
|
apiMonitorsMu.RUnlock()
|
|
|
|
if !exists {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": "该接口暂无监控数据",
|
|
})
|
|
return
|
|
}
|
|
|
|
calls, total := monitor.GetRecentESCalls(page, pageSize)
|
|
totalPages := (total + pageSize - 1) / pageSize
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "success",
|
|
"data": gin.H{
|
|
"calls": calls,
|
|
"count": len(calls),
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": pageSize,
|
|
"total_pages": totalPages,
|
|
},
|
|
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
|
})
|
|
}
|
|
|
|
// GetAPIRedisCalls 获取指定 API 的 Redis 调用记录
|
|
func (amc *APIMonitorController) GetAPIRedisCalls(c *gin.Context) {
|
|
endpoint := c.Query("endpoint")
|
|
if endpoint == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "缺少 endpoint 参数",
|
|
})
|
|
return
|
|
}
|
|
|
|
pageStr := c.DefaultQuery("page", "1")
|
|
pageSizeStr := c.DefaultQuery("page_size", "50")
|
|
|
|
page, err := strconv.Atoi(pageStr)
|
|
if err != nil || page <= 0 {
|
|
page = 1
|
|
}
|
|
|
|
pageSize, err := strconv.Atoi(pageSizeStr)
|
|
if err != nil || pageSize <= 0 {
|
|
pageSize = 50
|
|
}
|
|
if pageSize > 500 {
|
|
pageSize = 500
|
|
}
|
|
|
|
apiMonitorsMu.RLock()
|
|
monitor, exists := apiMonitors[endpoint]
|
|
apiMonitorsMu.RUnlock()
|
|
|
|
if !exists {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": "该接口暂无监控数据",
|
|
})
|
|
return
|
|
}
|
|
|
|
calls, total := monitor.GetRecentRedisCalls(page, pageSize)
|
|
totalPages := (total + pageSize - 1) / pageSize
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "success",
|
|
"data": gin.H{
|
|
"calls": calls,
|
|
"count": len(calls),
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": pageSize,
|
|
"total_pages": totalPages,
|
|
},
|
|
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
|
})
|
|
}
|
|
|
|
// GetAPIMonitorDashboard 获取 API 监控仪表板
|
|
func (amc *APIMonitorController) GetAPIMonitorDashboard(c *gin.Context) {
|
|
dashboardHTML := `<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>API 监控仪表板</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; }
|
|
.container { max-width: 1800px; margin: 0 auto; }
|
|
.card { background: white; border-radius: 8px; padding: 20px; margin: 20px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
|
|
.stat-item { text-align: center; padding: 15px; background: #f8f9fa; border-radius: 6px; }
|
|
.stat-value { font-size: 24px; font-weight: bold; color: #007bff; }
|
|
.stat-label { font-size: 14px; color: #666; margin-top: 5px; }
|
|
.success { color: #28a745; }
|
|
.warning { color: #ffc107; }
|
|
.danger { color: #dc3545; }
|
|
.btn { padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; margin: 5px; }
|
|
.btn:hover { background: #0056b3; }
|
|
.table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 12px; }
|
|
.table th, .table td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
|
|
.table th { background-color: #f8f9fa; font-weight: bold; }
|
|
.query-text { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: monospace; cursor: pointer; }
|
|
.query-text:hover { background-color: #e9ecef; }
|
|
.endpoint-card { border-left: 4px solid #007bff; }
|
|
.qps-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; }
|
|
.qps-high { background: #dc3545; color: white; }
|
|
.qps-medium { background: #ffc107; color: black; }
|
|
.qps-low { background: #28a745; color: white; }
|
|
.tab { display: flex; gap: 10px; margin-bottom: 15px; }
|
|
.tab-btn { padding: 8px 16px; border: none; background: #e9ecef; cursor: pointer; border-radius: 4px; }
|
|
.tab-btn.active { background: #007bff; color: white; }
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); }
|
|
.modal-content { background-color: white; margin: 2% auto; padding: 20px; border-radius: 8px; width: 90%; max-height: 90vh; overflow-y: auto; }
|
|
.close { color: #aaa; float: right; font-size: 28px; font-weight: bold; cursor: pointer; }
|
|
.close:hover { color: black; }
|
|
.detail-section { margin: 15px 0; }
|
|
.detail-label { font-weight: bold; color: #333; margin-bottom: 5px; }
|
|
.detail-value { background: #f8f9fa; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; }
|
|
.json-viewer { background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 4px; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; line-height: 1.5; }
|
|
.loading { text-align: center; padding: 20px; color: #666; }
|
|
.pagination { display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 15px; }
|
|
.pagination button { padding: 6px 12px; border: 1px solid #007bff; background: white; color: #007bff; cursor: pointer; border-radius: 4px; }
|
|
.pagination button:hover:not(:disabled) { background: #007bff; color: white; }
|
|
.pagination button:disabled { border-color: #ccc; color: #ccc; cursor: not-allowed; }
|
|
.pagination button.active { background: #007bff; color: white; }
|
|
.pagination-info { margin: 0 10px; font-size: 13px; color: #666; }
|
|
.page-size-selector { margin-left: 10px; }
|
|
.page-size-selector select { padding: 6px; border: 1px solid #007bff; border-radius: 4px; color: #007bff; cursor: pointer; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🔍 API 监控仪表板 - ES & Redis 调用</h1>
|
|
|
|
<div class="controls">
|
|
<button class="btn" onclick="refreshData()">立即刷新</button>
|
|
<label>
|
|
<input type="checkbox" id="autoRefresh" checked> 自动刷新 (5 秒)
|
|
</label>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>所有接口概览</h2>
|
|
<div id="endpointsList"></div>
|
|
</div>
|
|
|
|
<div class="card endpoint-card">
|
|
<h2>详细监控数据</h2>
|
|
<div class="tab">
|
|
<button class="tab-btn active" onclick="switchTab('stats', event)">📊 统计信息</button>
|
|
<button class="tab-btn" onclick="switchTab('es-calls', event)">🔵 ES 调用</button>
|
|
<button class="tab-btn" onclick="switchTab('redis-calls', event)">🔴 Redis 调用</button>
|
|
</div>
|
|
|
|
<div id="statsTab" class="tab-content active">
|
|
<div class="stats-grid" id="statsGrid"></div>
|
|
</div>
|
|
|
|
<div id="es-callsTab" class="tab-content">
|
|
<div id="esCallsContent"></div>
|
|
<div id="esCallsPagination" class="pagination"></div>
|
|
</div>
|
|
|
|
<div id="redis-callsTab" class="tab-content">
|
|
<div id="redisCallsContent"></div>
|
|
<div id="redisCallsPagination" class="pagination"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 详情弹窗 -->
|
|
<div id="detailModal" class="modal">
|
|
<div class="modal-content">
|
|
<span class="close" onclick="closeModal()">×</span>
|
|
<h2 id="modalTitle">调用详情</h2>
|
|
<div id="modalContent"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let autoRefreshInterval;
|
|
let currentEndpoint = '';
|
|
let esCurrentPage = 1;
|
|
let esPageSize = 50;
|
|
let esTotalPages = 1;
|
|
let redisCurrentPage = 1;
|
|
let redisPageSize = 50;
|
|
let redisTotalPages = 1;
|
|
|
|
function refreshData() {
|
|
loadAllEndpoints();
|
|
if (currentEndpoint) {
|
|
loadEndpointDetails(currentEndpoint);
|
|
}
|
|
}
|
|
|
|
function loadAllEndpoints() {
|
|
fetch('/api/api-monitor/all-stats')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const endpoints = data.data;
|
|
const container = document.getElementById('endpointsList');
|
|
|
|
if (!endpoints || endpoints.length === 0) {
|
|
container.innerHTML = '<p>暂无监控数据</p>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table class="table"><thead><tr><th>接口</th><th>总调用</th><th>ES 调用</th><th>Redis 调用</th><th>ES QPS</th><th>Redis QPS</th><th>成功率</th><th>平均耗时</th></tr></thead><tbody>';
|
|
endpoints.forEach(function(stat) {
|
|
var esQpsClass = stat.es_qps > 10 ? 'qps-high' : stat.es_qps > 1 ? 'qps-medium' : 'qps-low';
|
|
var redisQpsClass = stat.redis_qps > 10 ? 'qps-high' : stat.redis_qps > 1 ? 'qps-medium' : 'qps-low';
|
|
var successClass = stat.success_rate >= 95 ? 'success' : stat.success_rate >= 90 ? 'warning' : 'danger';
|
|
|
|
html += '<tr onclick="selectEndpoint(\'' + stat.endpoint + '\')" style="cursor: pointer;">' +
|
|
'<td>' + stat.endpoint + '</td>' +
|
|
'<td>' + stat.total_calls + '</td>' +
|
|
'<td>' + stat.es_calls + '</td>' +
|
|
'<td>' + stat.redis_calls + '</td>' +
|
|
'<td><span class="qps-badge ' + esQpsClass + '">' + stat.es_qps.toFixed(2) + '</span></td>' +
|
|
'<td><span class="qps-badge ' + redisQpsClass + '">' + stat.redis_qps.toFixed(2) + '</span></td>' +
|
|
'<td class="' + successClass + '">' + stat.success_rate.toFixed(2) + '%</td>' +
|
|
'<td>' + stat.avg_duration_ms + 'ms</td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
})
|
|
.catch(function(error) { console.error('加载接口列表失败:', error); });
|
|
}
|
|
|
|
function selectEndpoint(endpoint) {
|
|
currentEndpoint = endpoint;
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
|
btn.classList.remove('active');
|
|
});
|
|
|
|
const firstTab = document.querySelector('.tab-btn');
|
|
if (firstTab) {
|
|
firstTab.classList.add('active');
|
|
}
|
|
|
|
loadEndpointDetails(endpoint);
|
|
}
|
|
|
|
function loadEndpointDetails(endpoint) {
|
|
fetch('/api/api-monitor/stats?endpoint=' + encodeURIComponent(endpoint))
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const stat = data.data;
|
|
const statsGrid = document.getElementById('statsGrid');
|
|
var successClass = stat.success_rate >= 95 ? 'success' : 'warning';
|
|
|
|
statsGrid.innerHTML = '' +
|
|
'<div class="stat-item">' +
|
|
'<div class="stat-value">' + stat.total_calls + '</div>' +
|
|
'<div class="stat-label">总调用次数</div>' +
|
|
'</div>' +
|
|
'<div class="stat-item">' +
|
|
'<div class="stat-value">' + stat.es_calls + '</div>' +
|
|
'<div class="stat-label">ES 调用</div>' +
|
|
'</div>' +
|
|
'<div class="stat-item">' +
|
|
'<div class="stat-value">' + stat.redis_calls + '</div>' +
|
|
'<div class="stat-label">Redis 调用</div>' +
|
|
'</div>' +
|
|
'<div class="stat-item">' +
|
|
'<div class="stat-value">' + stat.es_qps.toFixed(2) + '</div>' +
|
|
'<div class="stat-label">ES QPS</div>' +
|
|
'</div>' +
|
|
'<div class="stat-item">' +
|
|
'<div class="stat-value">' + stat.redis_qps.toFixed(2) + '</div>' +
|
|
'<div class="stat-label">Redis QPS</div>' +
|
|
'</div>' +
|
|
'<div class="stat-item">' +
|
|
'<div class="stat-value ' + successClass + '">' + stat.success_rate.toFixed(2) + '%</div>' +
|
|
'<div class="stat-label">成功率</div>' +
|
|
'</div>' +
|
|
'<div class="stat-item">' +
|
|
'<div class="stat-value">' + stat.avg_duration_ms + 'ms</div>' +
|
|
'<div class="stat-label">平均耗时</div>' +
|
|
'</div>' +
|
|
'<div class="stat-item">' +
|
|
'<div class="stat-value">' + stat.last_update + '</div>' +
|
|
'<div class="stat-label">最后更新</div>' +
|
|
'</div>';
|
|
})
|
|
.catch(function(error) { console.error('加载统计数据失败:', error); });
|
|
|
|
loadESCalls(endpoint);
|
|
loadRedisCalls(endpoint);
|
|
}
|
|
|
|
function loadESCalls(endpoint) {
|
|
fetch('/api/api-monitor/es-calls?endpoint=' + encodeURIComponent(endpoint) + '&page=' + esCurrentPage + '&page_size=' + esPageSize)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (!data.data || !data.data.calls) {
|
|
const container = document.getElementById('esCallsContent');
|
|
container.innerHTML = '<p>暂无 ES 调用记录</p>';
|
|
document.getElementById('esCallsPagination').innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const calls = data.data.calls;
|
|
const container = document.getElementById('esCallsContent');
|
|
|
|
if (!calls || calls.length === 0) {
|
|
container.innerHTML = '<p>暂无 ES 调用记录</p>';
|
|
document.getElementById('esCallsPagination').innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
esTotalPages = data.data.total_pages || 1;
|
|
const total = data.data.total || 0;
|
|
|
|
let html = '<table class="table"><thead><tr><th>ID</th><th>操作</th><th>索引</th><th>查询</th><th>耗时 (ms)</th><th>时间</th><th>状态</th><th>详情</th></tr></thead><tbody>';
|
|
calls.forEach(function(call) {
|
|
var durationClass = call.duration_ms > 1000 ? 'danger' : call.duration_ms > 500 ? 'warning' : '';
|
|
var statusClass = call.success ? 'success' : 'danger';
|
|
|
|
html += '<tr>' +
|
|
'<td>' + call.id + '</td>' +
|
|
'<td>' + call.operation + '</td>' +
|
|
'<td>' + call.key_or_index + '</td>' +
|
|
'<td class="query-text" title="' + escapeHtml(call.query) + '" onclick="viewCallDetail(' + call.id + ', \'es\')">' + (call.query || '-') + '</td>' +
|
|
'<td class="' + durationClass + '">' + call.duration_ms + '</td>' +
|
|
'<td>' + new Date(call.timestamp).toLocaleString() + '</td>' +
|
|
'<td class="' + statusClass + '">' + (call.success ? '成功' : '失败') + '</td>' +
|
|
'<td><button class="btn" style="padding: 4px 8px; font-size: 11px;" onclick="viewCallDetail(' + call.id + ', \'es\')">查看详情</button></td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
|
|
renderESPagination(total);
|
|
})
|
|
.catch(function(error) { console.error('加载 ES 调用记录失败:', error); });
|
|
}
|
|
|
|
function renderESPagination(total) {
|
|
const container = document.getElementById('esCallsPagination');
|
|
if (esTotalPages <= 1) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
let html = '<button onclick="changeESPage(1)"' + (esCurrentPage === 1 ? ' disabled' : '') + '>首页</button>' +
|
|
'<button onclick="changeESPage(' + (esCurrentPage - 1) + ')"' + (esCurrentPage === 1 ? ' disabled' : '') + '>上一页</button>' +
|
|
'<span class="pagination-info">第 ' + esCurrentPage + ' / ' + esTotalPages + ' 页 (共 ' + total + ' 条)</span>' +
|
|
'<button onclick="changeESPage(' + (esCurrentPage + 1) + ')"' + (esCurrentPage === esTotalPages ? ' disabled' : '') + '>下一页</button>' +
|
|
'<button onclick="changeESPage(' + esTotalPages + ')"' + (esCurrentPage === esTotalPages ? ' disabled' : '') + '>末页</button>' +
|
|
'<div class="page-size-selector">' +
|
|
'<select onchange="changeESPageSize(this.value)">' +
|
|
'<option value="20"' + (esPageSize === 20 ? ' selected' : '') + '>20 条/页</option>' +
|
|
'<option value="50"' + (esPageSize === 50 ? ' selected' : '') + '>50 条/页</option>' +
|
|
'<option value="100"' + (esPageSize === 100 ? ' selected' : '') + '>100 条/页</option>' +
|
|
'<option value="200"' + (esPageSize === 200 ? ' selected' : '') + '>200 条/页</option>' +
|
|
'</select>' +
|
|
'</div>';
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function changeESPage(page) {
|
|
if (page < 1 || page > esTotalPages) return;
|
|
esCurrentPage = page;
|
|
loadESCalls(currentEndpoint);
|
|
}
|
|
|
|
function changeESPageSize(size) {
|
|
esPageSize = parseInt(size);
|
|
esCurrentPage = 1;
|
|
loadESCalls(currentEndpoint);
|
|
}
|
|
|
|
function loadRedisCalls(endpoint) {
|
|
fetch('/api/api-monitor/redis-calls?endpoint=' + encodeURIComponent(endpoint) + '&page=' + redisCurrentPage + '&page_size=' + redisPageSize)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (!data.data || !data.data.calls) {
|
|
const container = document.getElementById('redisCallsContent');
|
|
container.innerHTML = '<p>暂无 Redis 调用记录</p>';
|
|
document.getElementById('redisCallsPagination').innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const calls = data.data.calls;
|
|
const container = document.getElementById('redisCallsContent');
|
|
|
|
if (!calls || calls.length === 0) {
|
|
container.innerHTML = '<p>暂无 Redis 调用记录</p>';
|
|
document.getElementById('redisCallsPagination').innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
redisTotalPages = data.data.total_pages || 1;
|
|
const total = data.data.total || 0;
|
|
|
|
let html = '<table class="table"><thead><tr><th>ID</th><th>操作</th><th>Key</th><th>值</th><th>耗时 (ms)</th><th>时间</th><th>状态</th><th>详情</th></tr></thead><tbody>';
|
|
calls.forEach(function(call) {
|
|
var durationClass = call.duration_ms > 100 ? 'danger' : call.duration_ms > 50 ? 'warning' : '';
|
|
var statusClass = call.success ? 'success' : 'danger';
|
|
|
|
html += '<tr>' +
|
|
'<td>' + call.id + '</td>' +
|
|
'<td>' + call.operation + '</td>' +
|
|
'<td>' + call.key_or_index + '</td>' +
|
|
'<td class="query-text" title="' + escapeHtml(call.query) + '" onclick="viewCallDetail(' + call.id + ', \'redis\')">' + (call.query || '-') + '</td>' +
|
|
'<td class="' + durationClass + '">' + call.duration_ms + '</td>' +
|
|
'<td>' + new Date(call.timestamp).toLocaleString() + '</td>' +
|
|
'<td class="' + statusClass + '">' + (call.success ? '成功' : '失败') + '</td>' +
|
|
'<td><button class="btn" style="padding: 4px 8px; font-size: 11px;" onclick="viewCallDetail(' + call.id + ', \'redis\')">查看详情</button></td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
|
|
renderRedisPagination(total);
|
|
})
|
|
.catch(function(error) { console.error('加载 Redis 调用记录失败:', error); });
|
|
}
|
|
|
|
function renderRedisPagination(total) {
|
|
const container = document.getElementById('redisCallsPagination');
|
|
if (redisTotalPages <= 1) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
let html = '<button onclick="changeRedisPage(1)"' + (redisCurrentPage === 1 ? ' disabled' : '') + '>首页</button>' +
|
|
'<button onclick="changeRedisPage(' + (redisCurrentPage - 1) + ')"' + (redisCurrentPage === 1 ? ' disabled' : '') + '>上一页</button>' +
|
|
'<span class="pagination-info">第 ' + redisCurrentPage + ' / ' + redisTotalPages + ' 页 (共 ' + total + ' 条)</span>' +
|
|
'<button onclick="changeRedisPage(' + (redisCurrentPage + 1) + ')"' + (redisCurrentPage === redisTotalPages ? ' disabled' : '') + '>下一页</button>' +
|
|
'<button onclick="changeRedisPage(' + redisTotalPages + ')"' + (redisCurrentPage === redisTotalPages ? ' disabled' : '') + '>末页</button>' +
|
|
'<div class="page-size-selector">' +
|
|
'<select onchange="changeRedisPageSize(this.value)">' +
|
|
'<option value="20"' + (redisPageSize === 20 ? ' selected' : '') + '>20 条/页</option>' +
|
|
'<option value="50"' + (redisPageSize === 50 ? ' selected' : '') + '>50 条/页</option>' +
|
|
'<option value="100"' + (redisPageSize === 100 ? ' selected' : '') + '>100 条/页</option>' +
|
|
'<option value="200"' + (redisPageSize === 200 ? ' selected' : '') + '>200 条/页</option>' +
|
|
'</select>' +
|
|
'</div>';
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function changeRedisPage(page) {
|
|
if (page < 1 || page > redisTotalPages) return;
|
|
redisCurrentPage = page;
|
|
loadRedisCalls(currentEndpoint);
|
|
}
|
|
|
|
function changeRedisPageSize(size) {
|
|
redisPageSize = parseInt(size);
|
|
redisCurrentPage = 1;
|
|
loadRedisCalls(currentEndpoint);
|
|
}
|
|
|
|
function viewCallDetail(callId, type) {
|
|
const modal = document.getElementById('detailModal');
|
|
const modalTitle = document.getElementById('modalTitle');
|
|
const modalContent = document.getElementById('modalContent');
|
|
|
|
modalTitle.innerText = (type === 'es' ? 'ES' : 'Redis') + ' 调用详情 - ID: ' + callId;
|
|
modalContent.innerHTML = '<div class="loading">加载中...</div>';
|
|
modal.style.display = 'block';
|
|
|
|
fetch('/api/api-monitor/call-detail?endpoint=' + encodeURIComponent(currentEndpoint) + '&call_id=' + callId + '&type=' + type)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const detail = data.data;
|
|
let html = '' +
|
|
'<div class="detail-section">' +
|
|
'<div class="detail-label">基本信息</div>' +
|
|
'<table class="table">' +
|
|
'<tr><th width="150">调用 ID</th><td>' + detail.id + '</td></tr>' +
|
|
'<tr><th>类型</th><td>' + detail.type + '</td></tr>' +
|
|
'<tr><th>时间</th><td>' + detail.timestamp + '</td></tr>' +
|
|
'<tr><th>操作</th><td>' + detail.operation + '</td></tr>' +
|
|
'<tr><th>耗时</th><td class="' + (detail.duration_ms > 500 ? 'danger' : 'success') + '">' + detail.duration_ms + ' ms</td></tr>' +
|
|
'<tr><th>状态</th><td class="' + (detail.success ? 'success' : 'danger') + '">' + (detail.success ? '✓ 成功' : '✗ 失败') + '</td></tr>' +
|
|
(detail.error ? '<tr><th>错误信息</th><td class="danger">' + escapeHtml(detail.error) + '</td></tr>' : '') +
|
|
'</table>' +
|
|
'</div>' +
|
|
'<div class="detail-section">' +
|
|
'<div class="detail-label">查询信息</div>' +
|
|
'<table class="table">' +
|
|
'<tr><th>索引/Key</th><td>' + escapeHtml(detail.key_or_index) + '</td></tr>' +
|
|
'<tr><th>查询内容</th><td><div class="json-viewer">' + formatJSON(detail.query) + '</div></td></tr>' +
|
|
'</table>' +
|
|
'</div>';
|
|
|
|
if (detail.request) {
|
|
html += '<div class="detail-section">' +
|
|
'<div class="detail-label">请求内容</div>' +
|
|
'<div class="json-viewer">' + formatJSON(detail.request) + '</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
if (detail.response) {
|
|
html += '<div class="detail-section">' +
|
|
'<div class="detail-label">响应内容</div>' +
|
|
'<div class="json-viewer">' + formatJSON(detail.response) + '</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
modalContent.innerHTML = html;
|
|
})
|
|
.catch(function(error) {
|
|
modalContent.innerHTML = '<div class="danger">加载详情失败:' + error + '</div>';
|
|
});
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('detailModal').style.display = 'none';
|
|
}
|
|
|
|
function switchTab(tabName, event) {
|
|
document.querySelectorAll('.tab-content').forEach(function(content) {
|
|
content.classList.remove('active');
|
|
});
|
|
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
|
btn.classList.remove('active');
|
|
});
|
|
var targetTab = document.getElementById(tabName + 'Tab');
|
|
if (targetTab) {
|
|
targetTab.classList.add('active');
|
|
}
|
|
if (event && event.target) {
|
|
event.target.classList.add('active');
|
|
}
|
|
}
|
|
|
|
function toggleAutoRefresh() {
|
|
const checkbox = document.getElementById('autoRefresh');
|
|
if (checkbox.checked) {
|
|
autoRefreshInterval = setInterval(refreshData, 5000);
|
|
} else {
|
|
clearInterval(autoRefreshInterval);
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
return text.toString()
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function formatJSON(jsonStr) {
|
|
if (!jsonStr) return '';
|
|
try {
|
|
if (typeof jsonStr === 'string') {
|
|
const obj = JSON.parse(jsonStr);
|
|
return JSON.stringify(obj, null, 2);
|
|
}
|
|
return JSON.stringify(jsonStr, null, 2);
|
|
} catch (e) {
|
|
return jsonStr;
|
|
}
|
|
}
|
|
|
|
window.onclick = function(event) {
|
|
const modal = document.getElementById('detailModal');
|
|
if (event.target == modal) {
|
|
closeModal();
|
|
}
|
|
}
|
|
|
|
document.getElementById('autoRefresh').addEventListener('change', toggleAutoRefresh);
|
|
refreshData();
|
|
toggleAutoRefresh();
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.String(http.StatusOK, dashboardHTML)
|
|
}
|