mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 16:41:54 +08:00
特性(代理):通过端口和身份验证更新来增强服务器配置和安全性。
- 更新 docker-compose.yml 文件,使用端口 3336 - 修改 Dockerfile 以暴露端口 3336 - 重构 main.go 以支持新的路由和管理端点 - 在 auth.go 中实现健壮的身份验证中间件 - 通过加强错误检查来改进指标处理。 - 添加用于安全类型转换的实用函数 - 引入请求ID生成功能,以便更好地进行追踪。
This commit is contained in:
parent
03ffec4760
commit
e89ef02205
@ -9,6 +9,6 @@ RUN mkdir -p /app/data && \
|
|||||||
chmod +x /app/proxy-go && \
|
chmod +x /app/proxy-go && \
|
||||||
apk add --no-cache ca-certificates tzdata
|
apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 3336
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
ENTRYPOINT ["/app/proxy-go"]
|
ENTRYPOINT ["/app/proxy-go"]
|
||||||
|
@ -3,7 +3,7 @@ services:
|
|||||||
image: woodchen/proxy-go:latest
|
image: woodchen/proxy-go:latest
|
||||||
container_name: proxy-go
|
container_name: proxy-go
|
||||||
ports:
|
ports:
|
||||||
- "3334:80"
|
- "3336:3336"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
@ -18,7 +18,7 @@ services:
|
|||||||
cpus: '0.25'
|
cpus: '0.25'
|
||||||
memory: 128M
|
memory: 128M
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3336/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
@ -3,6 +3,9 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -60,3 +63,51 @@ func (am *authManager) cleanExpiredTokens() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware 认证中间件
|
||||||
|
func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
if !h.auth.validateToken(token) {
|
||||||
|
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthHandler 处理认证请求
|
||||||
|
func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Password != h.config.Metrics.Password {
|
||||||
|
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := h.auth.generateToken()
|
||||||
|
h.auth.addToken(token, time.Duration(h.config.Metrics.TokenExpiry)*time.Second)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
141
internal/handler/config.go
Normal file
141
internal/handler/config.go
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"proxy-go/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigHandler 配置管理处理器
|
||||||
|
type ConfigHandler struct {
|
||||||
|
config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfigHandler 创建新的配置管理处理器
|
||||||
|
func NewConfigHandler(cfg *config.Config) *ConfigHandler {
|
||||||
|
return &ConfigHandler{
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP 实现http.Handler接口
|
||||||
|
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/metrics/config":
|
||||||
|
h.handleConfigPage(w, r)
|
||||||
|
case "/metrics/config/get":
|
||||||
|
h.handleGetConfig(w, r)
|
||||||
|
case "/metrics/config/save":
|
||||||
|
h.handleSaveConfig(w, r)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConfigPage 处理配置页面请求
|
||||||
|
func (h *ConfigHandler) handleConfigPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, "web/templates/config.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetConfig 处理获取配置请求
|
||||||
|
func (h *ConfigHandler) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// 读取当前配置文件
|
||||||
|
configData, err := os.ReadFile("data/config.json")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("读取配置文件失败: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(configData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSaveConfig 处理保存配置请求
|
||||||
|
func (h *ConfigHandler) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "方法不允许", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析新配置
|
||||||
|
var newConfig config.Config
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("解析配置失败: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证新配置
|
||||||
|
if err := h.validateConfig(&newConfig); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("配置验证失败: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将新配置格式化为JSON
|
||||||
|
configData, err := json.MarshalIndent(newConfig, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("格式化配置失败: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到临时文件
|
||||||
|
tempFile := "data/config.json.tmp"
|
||||||
|
if err := os.WriteFile(tempFile, configData, 0644); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("保存配置失败: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重命名临时文件为正式文件
|
||||||
|
if err := os.Rename(tempFile, "data/config.json"); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("更新配置文件失败: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新运行时配置
|
||||||
|
*h.config = newConfig
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"message": "配置已更新并生效"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateConfig 验证配置
|
||||||
|
func (h *ConfigHandler) validateConfig(cfg *config.Config) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("配置不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证MAP配置
|
||||||
|
if cfg.MAP == nil {
|
||||||
|
return fmt.Errorf("MAP配置不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, pathConfig := range cfg.MAP {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("路径不能为空")
|
||||||
|
}
|
||||||
|
if pathConfig.DefaultTarget == "" {
|
||||||
|
return fmt.Errorf("路径 %s 的默认目标不能为空", path)
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(pathConfig.DefaultTarget); err != nil {
|
||||||
|
return fmt.Errorf("路径 %s 的默认目标URL无效: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证FixedPaths配置
|
||||||
|
for _, fp := range cfg.FixedPaths {
|
||||||
|
if fp.Path == "" {
|
||||||
|
return fmt.Errorf("固定路径不能为空")
|
||||||
|
}
|
||||||
|
if fp.TargetURL == "" {
|
||||||
|
return fmt.Errorf("固定路径 %s 的目标URL不能为空", fp.Path)
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(fp.TargetURL); err != nil {
|
||||||
|
return fmt.Errorf("固定路径 %s 的目标URL无效: %v", fp.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -6,8 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"proxy-go/internal/metrics"
|
"proxy-go/internal/metrics"
|
||||||
"proxy-go/internal/models"
|
"proxy-go/internal/models"
|
||||||
|
"proxy-go/internal/utils"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -43,7 +43,6 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
stats := collector.GetStats()
|
stats := collector.GetStats()
|
||||||
|
|
||||||
if stats == nil {
|
if stats == nil {
|
||||||
// 返回默认值而不是错误
|
|
||||||
stats = map[string]interface{}{
|
stats = map[string]interface{}{
|
||||||
"uptime": uptime.String(),
|
"uptime": uptime.String(),
|
||||||
"active_requests": int64(0),
|
"active_requests": int64(0),
|
||||||
@ -64,23 +63,27 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保所有必要的字段都存在
|
totalRequests := utils.SafeInt64(stats["total_requests"])
|
||||||
|
totalErrors := utils.SafeInt64(stats["total_errors"])
|
||||||
|
totalBytes := utils.SafeInt64(stats["total_bytes"])
|
||||||
|
uptimeSeconds := uptime.Seconds()
|
||||||
|
|
||||||
metrics := Metrics{
|
metrics := Metrics{
|
||||||
Uptime: uptime.String(),
|
Uptime: uptime.String(),
|
||||||
ActiveRequests: safeInt64(stats["active_requests"]),
|
ActiveRequests: utils.SafeInt64(stats["active_requests"]),
|
||||||
TotalRequests: safeInt64(stats["total_requests"]),
|
TotalRequests: totalRequests,
|
||||||
TotalErrors: safeInt64(stats["total_errors"]),
|
TotalErrors: totalErrors,
|
||||||
ErrorRate: float64(safeInt64(stats["total_errors"])) / float64(max(safeInt64(stats["total_requests"]), 1)),
|
ErrorRate: float64(totalErrors) / float64(utils.Max(totalRequests, 1)),
|
||||||
NumGoroutine: safeInt(stats["num_goroutine"]),
|
NumGoroutine: utils.SafeInt(stats["num_goroutine"]),
|
||||||
MemoryUsage: safeString(stats["memory_usage"]),
|
MemoryUsage: utils.SafeString(stats["memory_usage"], "0 B"),
|
||||||
AverageResponseTime: safeString(stats["avg_response_time"]),
|
AverageResponseTime: utils.SafeString(stats["avg_response_time"], "0 ms"),
|
||||||
TotalBytes: safeInt64(stats["total_bytes"]),
|
TotalBytes: totalBytes,
|
||||||
BytesPerSecond: float64(safeInt64(stats["total_bytes"])) / metrics.Max(uptime.Seconds(), 1),
|
BytesPerSecond: float64(totalBytes) / utils.MaxFloat64(uptimeSeconds, 1),
|
||||||
RequestsPerSecond: float64(safeInt64(stats["total_requests"])) / metrics.Max(uptime.Seconds(), 1),
|
RequestsPerSecond: float64(totalRequests) / utils.MaxFloat64(uptimeSeconds, 1),
|
||||||
StatusCodeStats: safeStatusCodeStats(stats["status_code_stats"]),
|
StatusCodeStats: models.SafeStatusCodeStats(stats["status_code_stats"]),
|
||||||
TopPaths: safePathMetrics(stats["top_paths"]),
|
TopPaths: models.SafePathMetrics(stats["top_paths"]),
|
||||||
RecentRequests: safeRequestLogs(stats["recent_requests"]),
|
RecentRequests: models.SafeRequestLogs(stats["recent_requests"]),
|
||||||
TopReferers: safePathMetrics(stats["top_referers"]),
|
TopReferers: models.SafePathMetrics(stats["top_referers"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@ -88,765 +91,3 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("Error encoding metrics: %v", err)
|
log.Printf("Error encoding metrics: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助函数
|
|
||||||
func max(a, b int64) int64 {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改模板,添加登录页面
|
|
||||||
var loginTemplate = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Proxy-Go Metrics Login</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
.login-card {
|
|
||||||
background: white;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
.login-title {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.input-group {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
background: #4CAF50;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
background: #45a049;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color: red;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="login-card">
|
|
||||||
<h2 class="login-title">Metrics Login</h2>
|
|
||||||
<div id="error" class="error">密码错误</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="password" id="password" placeholder="请输入密码">
|
|
||||||
</div>
|
|
||||||
<button onclick="login()">登录</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function login() {
|
|
||||||
const password = document.getElementById('password').value;
|
|
||||||
fetch('/metrics/auth', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({password: password})
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
// 登录成功,保存token并跳转
|
|
||||||
response.json().then(data => {
|
|
||||||
localStorage.setItem('metricsToken', data.token);
|
|
||||||
window.location.href = '/metrics/dashboard';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 显示错误信息
|
|
||||||
document.getElementById('error').style.display = 'block';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
document.getElementById('error').style.display = 'block';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
|
|
||||||
// 修改原有的 metricsTemplate,添加 token 检查
|
|
||||||
var metricsTemplate = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Proxy-Go Metrics</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.metric {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
.metric:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.metric-label {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.metric-value {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
color: #666;
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
}
|
|
||||||
.refresh {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: #4CAF50;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.refresh:hover {
|
|
||||||
background: #45a049;
|
|
||||||
}
|
|
||||||
#lastUpdate {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 140px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
/* 添加表格样式 */
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
th, td {
|
|
||||||
padding: 8px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
background: #f8f9fa;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.status-badge {
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.status-2xx { background: #28a745; }
|
|
||||||
.status-3xx { background: #17a2b8; }
|
|
||||||
.status-4xx { background: #ffc107; }
|
|
||||||
.status-5xx { background: #dc3545; }
|
|
||||||
.status-other { background: #000000; }
|
|
||||||
.grid-container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.grid-container .card {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.chart-container {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.chart {
|
|
||||||
height: 200px;
|
|
||||||
margin-bottom: 50px;
|
|
||||||
position: relative;
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.chart h3 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
#timeRange {
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
.time-range-buttons {
|
|
||||||
margin: 15px 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
background: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
min-width: 80px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-btn:hover {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-btn.active {
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
border-color: #0056b3;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
padding: 10px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls select {
|
|
||||||
padding: 5px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
#statusCodes {
|
|
||||||
padding: 15px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 15px;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
background: white;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 80px;
|
|
||||||
max-width: 120px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.loading {
|
|
||||||
position: relative;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
.loading::after {
|
|
||||||
content: "加载中...";
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: rgba(255,255,255,0.9);
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.error-message {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1000;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.export-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
.export-btn:hover {
|
|
||||||
background: #218838;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Proxy-Go Metrics</h1>
|
|
||||||
|
|
||||||
<div class="grid-container">
|
|
||||||
<div class="card">
|
|
||||||
<h2>基础指标</h2>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">运行时间</span>
|
|
||||||
<span class="metric-value" id="uptime"></span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">当前活跃请求</span>
|
|
||||||
<span class="metric-value" id="activeRequests"></span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">总请求数</span>
|
|
||||||
<span class="metric-value" id="totalRequests"></span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">错误数</span>
|
|
||||||
<span class="metric-value" id="totalErrors"></span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">错误率</span>
|
|
||||||
<span class="metric-value" id="errorRate"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>系统指标</h2>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Goroutine数量</span>
|
|
||||||
<span class="metric-value" id="numGoroutine"></span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">内存使用</span>
|
|
||||||
<span class="metric-value" id="memoryUsage"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>性能指标</h2>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">平均响应时间</span>
|
|
||||||
<span class="metric-value" id="avgResponseTime"></span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">每秒请求数</span>
|
|
||||||
<span class="metric-value" id="requestsPerSecond"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>流量统计</h2>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">总传输字节</span>
|
|
||||||
<span class="metric-value" id="totalBytes"></span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">每传输</span>
|
|
||||||
<span class="metric-value" id="bytesPerSecond"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>状态码统计</h2>
|
|
||||||
<div id="statusCodes"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>热门路径 (Top 10)</h2>
|
|
||||||
<table id="topPaths">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>路径</th>
|
|
||||||
<th>请求数</th>
|
|
||||||
<th>错误数</th>
|
|
||||||
<th>平均延迟</th>
|
|
||||||
<th>传输大小</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>最近请求</h2>
|
|
||||||
<table id="recentRequests">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>时间</th>
|
|
||||||
<th>路径</th>
|
|
||||||
<th>状态</th>
|
|
||||||
<th>延迟</th>
|
|
||||||
<th>大小</th>
|
|
||||||
<th>客户端IP</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>热门引用来源 (Top 10)</h2>
|
|
||||||
<table id="topReferers">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>来源</th>
|
|
||||||
<th>请求数</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span id="lastUpdate"></span>
|
|
||||||
<button class="refresh" onclick="refreshMetrics()">刷新</button>
|
|
||||||
|
|
||||||
<div id="errorMessage" class="error-message"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 检查登录状态
|
|
||||||
const token = localStorage.getItem('metricsToken');
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/metrics/ui';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLatency(nanoseconds) {
|
|
||||||
if (nanoseconds < 1000) {
|
|
||||||
return nanoseconds + ' ns';
|
|
||||||
} else if (nanoseconds < 1000000) {
|
|
||||||
return (nanoseconds / 1000).toFixed(2) + ' µs';
|
|
||||||
} else if (nanoseconds < 1000000000) {
|
|
||||||
return (nanoseconds / 1000000).toFixed(2) + ' ms';
|
|
||||||
} else {
|
|
||||||
return (nanoseconds / 1000000000).toFixed(2) + ' s';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMetrics(data) {
|
|
||||||
// 更新现有指标
|
|
||||||
document.getElementById('uptime').textContent = data.uptime;
|
|
||||||
document.getElementById('activeRequests').textContent = data.active_requests;
|
|
||||||
document.getElementById('totalRequests').textContent = data.total_requests;
|
|
||||||
document.getElementById('totalErrors').textContent = data.total_errors;
|
|
||||||
document.getElementById('errorRate').textContent = (data.error_rate * 100).toFixed(2) + '%';
|
|
||||||
document.getElementById('numGoroutine').textContent = data.num_goroutine;
|
|
||||||
document.getElementById('memoryUsage').textContent = data.memory_usage;
|
|
||||||
document.getElementById('avgResponseTime').textContent = data.avg_response_time;
|
|
||||||
document.getElementById('requestsPerSecond').textContent = data.requests_per_second.toFixed(2);
|
|
||||||
|
|
||||||
// 更新流量统计
|
|
||||||
document.getElementById('totalBytes').textContent = formatBytes(data.total_bytes);
|
|
||||||
document.getElementById('bytesPerSecond').textContent = formatBytes(data.bytes_per_second) + '/s';
|
|
||||||
|
|
||||||
// 更新状态码计
|
|
||||||
const statusCodesHtml = '<div class="status-row">' +
|
|
||||||
Object.entries(data.status_code_stats || {})
|
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
||||||
.map(([status, count]) => {
|
|
||||||
const firstDigit = status.charAt(0);
|
|
||||||
const statusClass = (firstDigit >= '2' && firstDigit <= '5')
|
|
||||||
? 'status-' + firstDigit + 'xx'
|
|
||||||
: 'status-other';
|
|
||||||
return '<div class="status-item">' +
|
|
||||||
'<span class="status-badge ' + statusClass + '">' + status + '</span>' +
|
|
||||||
'<span class="metric-value">' + count.toLocaleString() + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('') +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
const statusCodesContainer = document.getElementById('statusCodes');
|
|
||||||
statusCodesContainer.innerHTML = statusCodesHtml;
|
|
||||||
|
|
||||||
// 更新热门路径
|
|
||||||
const topPathsHtml = (data.top_paths || []).map(path =>
|
|
||||||
'<tr>' +
|
|
||||||
'<td>' + path.path + '</td>' +
|
|
||||||
'<td>' + path.request_count + '</td>' +
|
|
||||||
'<td>' + path.error_count + '</td>' +
|
|
||||||
'<td>' + path.avg_latency + '</td>' +
|
|
||||||
'<td>' + formatBytes(path.bytes_transferred) + '</td>' +
|
|
||||||
'</tr>'
|
|
||||||
).join('');
|
|
||||||
document.querySelector('#topPaths tbody').innerHTML = topPathsHtml;
|
|
||||||
|
|
||||||
// 更新最近请求
|
|
||||||
const recentRequestsHtml = (data.recent_requests || []).map(req =>
|
|
||||||
'<tr>' +
|
|
||||||
'<td>' + formatDate(req.Time) + '</td>' +
|
|
||||||
'<td>' + req.Path + '</td>' +
|
|
||||||
'<td><span class="status-badge status-' + Math.floor(req.Status/100) + 'xx">' + req.Status + '</span></td>' +
|
|
||||||
'<td>' + formatLatency(req.Latency) + '</td>' +
|
|
||||||
'<td>' + formatBytes(req.BytesSent) + '</td>' +
|
|
||||||
'<td>' + req.ClientIP + '</td>' +
|
|
||||||
'</tr>'
|
|
||||||
).join('');
|
|
||||||
document.querySelector('#recentRequests tbody').innerHTML = recentRequestsHtml;
|
|
||||||
|
|
||||||
// 更新热门引用来源
|
|
||||||
const topReferersHtml = (data.top_referers || []).map(referer =>
|
|
||||||
'<tr>' +
|
|
||||||
'<td>' + referer.path + '</td>' +
|
|
||||||
'<td>' + referer.request_count + '</td>' +
|
|
||||||
'</tr>'
|
|
||||||
).join('');
|
|
||||||
document.querySelector('#topReferers tbody').innerHTML = topReferersHtml;
|
|
||||||
|
|
||||||
document.getElementById('lastUpdate').textContent = '最后更新: ' + new Date().toLocaleTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(message) {
|
|
||||||
const errorDiv = document.getElementById('errorMessage');
|
|
||||||
errorDiv.textContent = message;
|
|
||||||
errorDiv.style.display = 'block';
|
|
||||||
setTimeout(() => {
|
|
||||||
errorDiv.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshMetrics() {
|
|
||||||
fetch('/metrics', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer ' + token
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (response.status === 401) {
|
|
||||||
localStorage.removeItem('metricsToken');
|
|
||||||
window.location.href = '/metrics/ui';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('获取数据失败');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (data) updateMetrics(data);
|
|
||||||
})
|
|
||||||
.catch(error => showError(error.message));
|
|
||||||
}
|
|
||||||
|
|
||||||
let refreshTimer;
|
|
||||||
|
|
||||||
function setupAutoRefresh() {
|
|
||||||
// 清除已存在的定时器
|
|
||||||
if (refreshTimer) {
|
|
||||||
clearInterval(refreshTimer);
|
|
||||||
}
|
|
||||||
// 设置5秒自动刷新
|
|
||||||
refreshTimer = setInterval(refreshMetrics, 5 * 1000); // 改为 5 秒
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
refreshMetrics(); // 立即加载一次数据
|
|
||||||
setupAutoRefresh(); // 设置自动刷新
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
|
|
||||||
// 添加认证中间件
|
|
||||||
func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
auth := r.Header.Get("Authorization")
|
|
||||||
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimPrefix(auth, "Bearer ")
|
|
||||||
if !h.auth.validateToken(token) {
|
|
||||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改处理器
|
|
||||||
func (h *ProxyHandler) MetricsPageHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(loginTemplate))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProxyHandler) MetricsDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Write([]byte(metricsTemplate))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProxyHandler) MetricsAuthHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Password != h.config.Metrics.Password {
|
|
||||||
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := h.auth.generateToken()
|
|
||||||
h.auth.addToken(token, time.Duration(h.config.Metrics.TokenExpiry)*time.Second)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"token": token,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加安全的类型转换辅助函数
|
|
||||||
func safeStatusCodeStats(v interface{}) map[string]int64 {
|
|
||||||
if v == nil {
|
|
||||||
return make(map[string]int64)
|
|
||||||
}
|
|
||||||
if m, ok := v.(map[string]int64); ok {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
return make(map[string]int64)
|
|
||||||
}
|
|
||||||
|
|
||||||
func safePathMetrics(v interface{}) []models.PathMetrics {
|
|
||||||
if v == nil {
|
|
||||||
return []models.PathMetrics{}
|
|
||||||
}
|
|
||||||
if m, ok := v.([]models.PathMetrics); ok {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
return []models.PathMetrics{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeRequestLogs(v interface{}) []models.RequestLog {
|
|
||||||
if v == nil {
|
|
||||||
return []models.RequestLog{}
|
|
||||||
}
|
|
||||||
if m, ok := v.([]models.RequestLog); ok {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
return []models.RequestLog{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeInt64(v interface{}) int64 {
|
|
||||||
if v == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if i, ok := v.(int64); ok {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeInt(v interface{}) int {
|
|
||||||
if v == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if i, ok := v.(int); ok {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeString(v interface{}) string {
|
|
||||||
if v == nil {
|
|
||||||
return "0 B" // 返回默认值
|
|
||||||
}
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return "0 B" // 返回默认值
|
|
||||||
}
|
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"proxy-go/internal/config"
|
"proxy-go/internal/config"
|
||||||
@ -17,14 +20,201 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultBufferSize = 32 * 1024 // 32KB
|
smallBufferSize = 4 * 1024 // 4KB
|
||||||
|
mediumBufferSize = 32 * 1024 // 32KB
|
||||||
|
largeBufferSize = 64 * 1024 // 64KB
|
||||||
|
|
||||||
|
// 超时时间常量
|
||||||
|
clientConnTimeout = 3 * time.Second // 客户端连接超时
|
||||||
|
proxyRespTimeout = 10 * time.Second // 代理响应超时
|
||||||
|
backendServTimeout = 8 * time.Second // 后端服务超时
|
||||||
|
idleConnTimeout = 120 * time.Second // 空闲连接超时
|
||||||
|
tlsHandshakeTimeout = 5 * time.Second // TLS握手超时
|
||||||
|
|
||||||
|
// 限流相关常量
|
||||||
|
globalRateLimit = 1000 // 全局每秒请求数限制
|
||||||
|
globalBurstLimit = 200 // 全局突发请求数限制
|
||||||
|
perHostRateLimit = 100 // 每个host每秒请求数限制
|
||||||
|
perHostBurstLimit = 50 // 每个host突发请求数限制
|
||||||
|
perIPRateLimit = 20 // 每个IP每秒请求数限制
|
||||||
|
perIPBurstLimit = 10 // 每个IP突发请求数限制
|
||||||
|
cleanupInterval = 10 * time.Minute // 清理过期限流器的间隔
|
||||||
)
|
)
|
||||||
|
|
||||||
var bufferPool = sync.Pool{
|
// 定义不同大小的缓冲池
|
||||||
|
var (
|
||||||
|
smallBufferPool = sync.Pool{
|
||||||
New: func() interface{} {
|
New: func() interface{} {
|
||||||
buf := make([]byte, defaultBufferSize)
|
return bytes.NewBuffer(make([]byte, smallBufferSize))
|
||||||
return &buf
|
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mediumBufferPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return bytes.NewBuffer(make([]byte, mediumBufferSize))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
largeBufferPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return bytes.NewBuffer(make([]byte, largeBufferSize))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用于大文件传输的字节切片池
|
||||||
|
byteSlicePool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
b := make([]byte, largeBufferSize)
|
||||||
|
return &b
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// getBuffer 根据大小选择合适的缓冲池
|
||||||
|
func getBuffer(size int64) (*bytes.Buffer, func()) {
|
||||||
|
var buf *bytes.Buffer
|
||||||
|
var pool *sync.Pool
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case size <= smallBufferSize:
|
||||||
|
pool = &smallBufferPool
|
||||||
|
case size <= mediumBufferSize:
|
||||||
|
pool = &mediumBufferPool
|
||||||
|
default:
|
||||||
|
pool = &largeBufferPool
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = pool.Get().(*bytes.Buffer)
|
||||||
|
buf.Reset() // 重置缓冲区
|
||||||
|
|
||||||
|
return buf, func() {
|
||||||
|
if buf != nil {
|
||||||
|
pool.Put(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 hop-by-hop 头部映射
|
||||||
|
var hopHeadersMap = make(map[string]bool)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
headers := []string{
|
||||||
|
"Connection",
|
||||||
|
"Keep-Alive",
|
||||||
|
"Proxy-Authenticate",
|
||||||
|
"Proxy-Authorization",
|
||||||
|
"Proxy-Connection",
|
||||||
|
"Te",
|
||||||
|
"Trailer",
|
||||||
|
"Transfer-Encoding",
|
||||||
|
"Upgrade",
|
||||||
|
}
|
||||||
|
for _, h := range headers {
|
||||||
|
hopHeadersMap[h] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorHandler 定义错误处理函数类型
|
||||||
|
type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error)
|
||||||
|
|
||||||
|
// RateLimiter 定义限流器接口
|
||||||
|
type RateLimiter interface {
|
||||||
|
Allow() bool
|
||||||
|
Clean(now time.Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限流管理器
|
||||||
|
type rateLimitManager struct {
|
||||||
|
globalLimiter *rate.Limiter
|
||||||
|
hostLimiters *sync.Map // host -> *rate.Limiter
|
||||||
|
ipLimiters *sync.Map // IP -> *rate.Limiter
|
||||||
|
lastCleanup time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的限流管理器
|
||||||
|
func newRateLimitManager() *rateLimitManager {
|
||||||
|
manager := &rateLimitManager{
|
||||||
|
globalLimiter: rate.NewLimiter(rate.Limit(globalRateLimit), globalBurstLimit),
|
||||||
|
hostLimiters: &sync.Map{},
|
||||||
|
ipLimiters: &sync.Map{},
|
||||||
|
lastCleanup: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动清理协程
|
||||||
|
go manager.cleanupLoop()
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *rateLimitManager) cleanupLoop() {
|
||||||
|
ticker := time.NewTicker(cleanupInterval)
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
m.cleanup(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *rateLimitManager) cleanup(now time.Time) {
|
||||||
|
m.hostLimiters.Range(func(key, value interface{}) bool {
|
||||||
|
if now.Sub(m.lastCleanup) > cleanupInterval {
|
||||||
|
m.hostLimiters.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
m.ipLimiters.Range(func(key, value interface{}) bool {
|
||||||
|
if now.Sub(m.lastCleanup) > cleanupInterval {
|
||||||
|
m.ipLimiters.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
m.lastCleanup = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *rateLimitManager) getHostLimiter(host string) *rate.Limiter {
|
||||||
|
if limiter, exists := m.hostLimiters.Load(host); exists {
|
||||||
|
return limiter.(*rate.Limiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter := rate.NewLimiter(rate.Limit(perHostRateLimit), perHostBurstLimit)
|
||||||
|
m.hostLimiters.Store(host, limiter)
|
||||||
|
return limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *rateLimitManager) getIPLimiter(ip string) *rate.Limiter {
|
||||||
|
if limiter, exists := m.ipLimiters.Load(ip); exists {
|
||||||
|
return limiter.(*rate.Limiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter := rate.NewLimiter(rate.Limit(perIPRateLimit), perIPBurstLimit)
|
||||||
|
m.ipLimiters.Store(ip, limiter)
|
||||||
|
return limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否允许请求
|
||||||
|
func (m *rateLimitManager) allowRequest(r *http.Request) error {
|
||||||
|
// 全局限流检查
|
||||||
|
if !m.globalLimiter.Allow() {
|
||||||
|
return fmt.Errorf("global rate limit exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host限流检查
|
||||||
|
host := r.Host
|
||||||
|
if host != "" {
|
||||||
|
if !m.getHostLimiter(host).Allow() {
|
||||||
|
return fmt.Errorf("host rate limit exceeded for %s", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP限流检查
|
||||||
|
ip := utils.GetClientIP(r)
|
||||||
|
if ip != "" {
|
||||||
|
if !m.getIPLimiter(ip).Allow() {
|
||||||
|
return fmt.Errorf("ip rate limit exceeded for %s", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProxyHandler struct {
|
type ProxyHandler struct {
|
||||||
@ -34,41 +224,106 @@ type ProxyHandler struct {
|
|||||||
startTime time.Time
|
startTime time.Time
|
||||||
config *config.Config
|
config *config.Config
|
||||||
auth *authManager
|
auth *authManager
|
||||||
|
errorHandler ErrorHandler // 添加错误处理器
|
||||||
|
rateLimiter *rateLimitManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改参数类型
|
// 修改参数类型
|
||||||
func NewProxyHandler(cfg *config.Config) *ProxyHandler {
|
func NewProxyHandler(cfg *config.Config) *ProxyHandler {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: clientConnTimeout, // 客户端连接超时
|
||||||
|
KeepAlive: 30 * time.Second, // TCP keepalive 间隔
|
||||||
|
}
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
MaxIdleConns: 100, // 最大空闲连接数
|
DialContext: dialer.DialContext,
|
||||||
MaxIdleConnsPerHost: 10, // 每个 host 的最大空闲连接数
|
MaxIdleConns: 200,
|
||||||
IdleConnTimeout: 90 * time.Second, // 空闲连接超时时间
|
MaxIdleConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: idleConnTimeout, // 空闲连接超时
|
||||||
|
TLSHandshakeTimeout: tlsHandshakeTimeout, // TLS握手超时
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
MaxConnsPerHost: 50,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
DisableCompression: false,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
WriteBufferSize: 64 * 1024,
|
||||||
|
ReadBufferSize: 64 * 1024,
|
||||||
|
ResponseHeaderTimeout: backendServTimeout, // 后端服务响应头超时
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ProxyHandler{
|
return &ProxyHandler{
|
||||||
pathMap: cfg.MAP,
|
pathMap: cfg.MAP,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: proxyRespTimeout, // 整体代理响应超时
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return fmt.Errorf("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
limiter: rate.NewLimiter(rate.Limit(5000), 10000),
|
limiter: rate.NewLimiter(rate.Limit(5000), 10000),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
auth: newAuthManager(),
|
auth: newAuthManager(),
|
||||||
|
rateLimiter: newRateLimitManager(),
|
||||||
|
errorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
log.Printf("[Error] %s %s -> %v", r.Method, r.URL.Path, err)
|
||||||
|
if strings.Contains(err.Error(), "rate limit exceeded") {
|
||||||
|
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetErrorHandler 允许自定义错误处理函数
|
||||||
|
func (h *ProxyHandler) SetErrorHandler(handler ErrorHandler) {
|
||||||
|
if handler != nil {
|
||||||
|
h.errorHandler = handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyResponse 使用零拷贝方式传输数据
|
||||||
|
func copyResponse(dst io.Writer, src io.Reader, flusher http.Flusher) (int64, error) {
|
||||||
|
buf := byteSlicePool.Get().(*[]byte)
|
||||||
|
defer byteSlicePool.Put(buf)
|
||||||
|
|
||||||
|
written, err := io.CopyBuffer(dst, src, *buf)
|
||||||
|
if err == nil && flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 添加 panic 恢复
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
log.Printf("[Panic] %s %s -> %v", r.Method, r.URL.Path, err)
|
||||||
|
h.errorHandler(w, r, fmt.Errorf("panic: %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
collector := metrics.GetCollector()
|
collector := metrics.GetCollector()
|
||||||
collector.BeginRequest()
|
collector.BeginRequest()
|
||||||
defer collector.EndRequest()
|
defer collector.EndRequest()
|
||||||
|
|
||||||
if !h.limiter.Allow() {
|
// 限流检查
|
||||||
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
if err := h.rateLimiter.allowRequest(r); err != nil {
|
||||||
|
h.errorHandler(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
|
// 创建带超时的上下文
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), proxyRespTimeout)
|
||||||
|
defer cancel()
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
// 处理根路径请求
|
// 处理根路径请求
|
||||||
if r.URL.Path == "/" {
|
if r.URL.Path == "/" {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@ -103,7 +358,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// URL 解码,然后重新编码,确保特殊字符被正确处理
|
// URL 解码,然后重新编码,确保特殊字符被正确处理
|
||||||
decodedPath, err := url.QueryUnescape(targetPath)
|
decodedPath, err := url.QueryUnescape(targetPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Error decoding path", http.StatusInternalServerError)
|
h.errorHandler(w, r, fmt.Errorf("error decoding path: %v", err))
|
||||||
log.Printf("[%s] %s %s -> 500 (error decoding path: %v) [%v]",
|
log.Printf("[%s] %s %s -> 500 (error decoding path: %v) [%v]",
|
||||||
utils.GetClientIP(r), r.Method, r.URL.Path, err, time.Since(start))
|
utils.GetClientIP(r), r.Method, r.URL.Path, err, time.Since(start))
|
||||||
return
|
return
|
||||||
@ -123,41 +378,27 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 解析目标 URL 以获取 host
|
// 解析目标 URL 以获取 host
|
||||||
parsedURL, err := url.Parse(targetURL)
|
parsedURL, err := url.Parse(targetURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Error parsing target URL", http.StatusInternalServerError)
|
h.errorHandler(w, r, fmt.Errorf("error parsing URL: %v", err))
|
||||||
log.Printf("[%s] %s %s -> 500 (error parsing URL: %v) [%v]",
|
log.Printf("[%s] %s %s -> 500 (error parsing URL: %v) [%v]",
|
||||||
utils.GetClientIP(r), r.Method, r.URL.Path, err, time.Since(start))
|
utils.GetClientIP(r), r.Method, r.URL.Path, err, time.Since(start))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建新的请求
|
// 创建新的请求时使用带超时的上下文
|
||||||
proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
|
proxyReq, err := http.NewRequestWithContext(ctx, r.Method, targetURL, r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Error creating proxy request", http.StatusInternalServerError)
|
h.errorHandler(w, r, fmt.Errorf("error creating request: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制原始请求头
|
// 添加请求追踪标识
|
||||||
|
requestID := utils.GenerateRequestID()
|
||||||
|
proxyReq.Header.Set("X-Request-ID", requestID)
|
||||||
|
w.Header().Set("X-Request-ID", requestID)
|
||||||
|
|
||||||
|
// 复制并处理请求头
|
||||||
copyHeader(proxyReq.Header, r.Header)
|
copyHeader(proxyReq.Header, r.Header)
|
||||||
|
|
||||||
// 特别处理图片请求
|
|
||||||
// if utils.IsImageRequest(r.URL.Path) {
|
|
||||||
// // 设置优化的 Accept 头
|
|
||||||
// accept := r.Header.Get("Accept")
|
|
||||||
// if accept != "" {
|
|
||||||
// proxyReq.Header.Set("Accept", accept)
|
|
||||||
// } else {
|
|
||||||
// proxyReq.Header.Set("Accept", "image/avif,image/webp,image/jpeg,image/png,*/*;q=0.8")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 设置 Cloudflare 特定的头部
|
|
||||||
// proxyReq.Header.Set("CF-Accept-Content", "image/avif,image/webp")
|
|
||||||
// proxyReq.Header.Set("CF-Optimize-Images", "on")
|
|
||||||
|
|
||||||
// // 删除可能影响缓存的头部
|
|
||||||
// proxyReq.Header.Del("If-None-Match")
|
|
||||||
// proxyReq.Header.Del("If-Modified-Since")
|
|
||||||
// proxyReq.Header.Set("Cache-Control", "no-cache")
|
|
||||||
// }
|
|
||||||
// 特别处理图片请求
|
// 特别处理图片请求
|
||||||
if utils.IsImageRequest(r.URL.Path) {
|
if utils.IsImageRequest(r.URL.Path) {
|
||||||
// 获取 Accept 头
|
// 获取 Accept 头
|
||||||
@ -190,13 +431,31 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 Cookie 安全属性
|
||||||
|
if r.TLS != nil && len(proxyReq.Cookies()) > 0 {
|
||||||
|
cookies := proxyReq.Cookies()
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if !cookie.Secure {
|
||||||
|
cookie.Secure = true
|
||||||
|
}
|
||||||
|
if !cookie.HttpOnly {
|
||||||
|
cookie.HttpOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 发送代理请求
|
// 发送代理请求
|
||||||
resp, err := h.client.Do(proxyReq)
|
resp, err := h.client.Do(proxyReq)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Error forwarding request", http.StatusBadGateway)
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
h.errorHandler(w, r, fmt.Errorf("request timeout after %v", proxyRespTimeout))
|
||||||
|
log.Printf("[Timeout] %s %s -> timeout after %v",
|
||||||
|
r.Method, r.URL.Path, proxyRespTimeout)
|
||||||
|
} else {
|
||||||
|
h.errorHandler(w, r, fmt.Errorf("proxy error: %v", err))
|
||||||
log.Printf("[%s] %s %s -> 502 (proxy error: %v) [%v]",
|
log.Printf("[%s] %s %s -> 502 (proxy error: %v) [%v]",
|
||||||
utils.GetClientIP(r), r.Method, r.URL.Path, err, time.Since(start))
|
utils.GetClientIP(r), r.Method, r.URL.Path, err, time.Since(start))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@ -212,13 +471,19 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 根据响应大小选择不同的处理策略
|
// 根据响应大小选择不同的处理策略
|
||||||
contentLength := resp.ContentLength
|
contentLength := resp.ContentLength
|
||||||
if contentLength > 0 && contentLength < 1<<20 { // 1MB 以下的小响应
|
if contentLength > 0 && contentLength < 1<<20 { // 1MB 以下的小响应
|
||||||
// 直接读取到内存并一次性写入
|
// 获取合适大小的缓冲区
|
||||||
body, err := io.ReadAll(resp.Body)
|
buf, putBuffer := getBuffer(contentLength)
|
||||||
|
defer putBuffer()
|
||||||
|
|
||||||
|
// 使用缓冲区读取响应
|
||||||
|
_, err := io.Copy(buf, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Error reading response", http.StatusInternalServerError)
|
h.errorHandler(w, r, fmt.Errorf("error reading response: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
written, err := w.Write(body)
|
|
||||||
|
// 一次性写入响应
|
||||||
|
written, err := w.Write(buf.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !isConnectionClosed(err) {
|
if !isConnectionClosed(err) {
|
||||||
log.Printf("Error writing response: %v", err)
|
log.Printf("Error writing response: %v", err)
|
||||||
@ -226,38 +491,18 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), int64(written), utils.GetClientIP(r), r)
|
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), int64(written), utils.GetClientIP(r), r)
|
||||||
} else {
|
} else {
|
||||||
// 大响应使用流式传输
|
// 大响应使用零拷贝传输
|
||||||
var bytesCopied int64
|
var bytesCopied int64
|
||||||
if f, ok := w.(http.Flusher); ok {
|
var err error
|
||||||
bufPtr := bufferPool.Get().(*[]byte)
|
|
||||||
defer bufferPool.Put(bufPtr)
|
|
||||||
buf := *bufPtr
|
|
||||||
|
|
||||||
for {
|
if f, ok := w.(http.Flusher); ok {
|
||||||
n, rerr := resp.Body.Read(buf)
|
bytesCopied, err = copyResponse(w, resp.Body, f)
|
||||||
if n > 0 {
|
|
||||||
bytesCopied += int64(n)
|
|
||||||
_, werr := w.Write(buf[:n])
|
|
||||||
if werr != nil {
|
|
||||||
log.Printf("Error writing response: %v", werr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.Flush()
|
|
||||||
}
|
|
||||||
if rerr == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if rerr != nil {
|
|
||||||
log.Printf("Error reading response: %v", rerr)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 如果不支持 Flusher,使用普通的 io.Copy
|
bytesCopied, err = copyResponse(w, resp.Body, nil)
|
||||||
bytesCopied, err = io.Copy(w, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error copying response: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err != nil && !isConnectionClosed(err) {
|
||||||
|
log.Printf("Error copying response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录访问日志
|
// 记录访问日志
|
||||||
@ -277,11 +522,21 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func copyHeader(dst, src http.Header) {
|
func copyHeader(dst, src http.Header) {
|
||||||
|
// 处理 Connection 头部指定的其他 hop-by-hop 头部
|
||||||
|
if connection := src.Get("Connection"); connection != "" {
|
||||||
|
for _, h := range strings.Split(connection, ",") {
|
||||||
|
hopHeadersMap[strings.TrimSpace(h)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 map 快速查找,跳过 hop-by-hop 头部
|
||||||
for k, vv := range src {
|
for k, vv := range src {
|
||||||
|
if !hopHeadersMap[k] {
|
||||||
for _, v := range vv {
|
for _, v := range vv {
|
||||||
dst.Add(k, v)
|
dst.Add(k, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加辅助函数判断是否是连接关闭错误
|
// 添加辅助函数判断是否是连接关闭错误
|
||||||
|
34
internal/models/utils.go
Normal file
34
internal/models/utils.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// SafeStatusCodeStats 安全地将 interface{} 转换为状态码统计
|
||||||
|
func SafeStatusCodeStats(v interface{}) map[string]int64 {
|
||||||
|
if v == nil {
|
||||||
|
return make(map[string]int64)
|
||||||
|
}
|
||||||
|
if m, ok := v.(map[string]int64); ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return make(map[string]int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafePathMetrics 安全地将 interface{} 转换为路径指标
|
||||||
|
func SafePathMetrics(v interface{}) []PathMetrics {
|
||||||
|
if v == nil {
|
||||||
|
return []PathMetrics{}
|
||||||
|
}
|
||||||
|
if m, ok := v.([]PathMetrics); ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return []PathMetrics{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeRequestLogs 安全地将 interface{} 转换为请求日志
|
||||||
|
func SafeRequestLogs(v interface{}) []RequestLog {
|
||||||
|
if v == nil {
|
||||||
|
return []RequestLog{}
|
||||||
|
}
|
||||||
|
if m, ok := v.([]RequestLog); ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return []RequestLog{}
|
||||||
|
}
|
@ -2,6 +2,8 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@ -61,6 +63,16 @@ func init() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateRequestID 生成唯一的请求ID
|
||||||
|
func GenerateRequestID() string {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// 如果随机数生成失败,使用时间戳作为备选
|
||||||
|
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
func GetClientIP(r *http.Request) string {
|
func GetClientIP(r *http.Request) string {
|
||||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||||
return ip
|
return ip
|
||||||
@ -223,3 +235,52 @@ func isCacheHit(url string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SafeInt64 安全地将 interface{} 转换为 int64
|
||||||
|
func SafeInt64(v interface{}) int64 {
|
||||||
|
if v == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if i, ok := v.(int64); ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeInt 安全地将 interface{} 转换为 int
|
||||||
|
func SafeInt(v interface{}) int {
|
||||||
|
if v == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if i, ok := v.(int); ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeString 安全地将 interface{} 转换为 string
|
||||||
|
func SafeString(v interface{}, defaultValue string) string {
|
||||||
|
if v == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max 返回两个 int64 中的较大值
|
||||||
|
func Max(a, b int64) int64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxFloat64 返回两个 float64 中的较大值
|
||||||
|
func MaxFloat64(a, b float64) float64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
34
main.go
34
main.go
@ -75,20 +75,34 @@ func main() {
|
|||||||
|
|
||||||
// 创建主处理器
|
// 创建主处理器
|
||||||
mainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// 先处理监控路由
|
// 处理静态文件
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/web/static/") {
|
||||||
|
http.StripPrefix("/web/static/", http.FileServer(http.Dir("web/static"))).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理管理路由
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/admin/") {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/metrics":
|
case "/admin/login":
|
||||||
|
http.ServeFile(w, r, "web/templates/admin/login.html")
|
||||||
|
return
|
||||||
|
case "/admin/metrics":
|
||||||
proxyHandler.AuthMiddleware(proxyHandler.MetricsHandler)(w, r)
|
proxyHandler.AuthMiddleware(proxyHandler.MetricsHandler)(w, r)
|
||||||
return
|
return
|
||||||
case "/metrics/ui":
|
case "/admin/config":
|
||||||
proxyHandler.MetricsPageHandler(w, r)
|
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
|
||||||
return
|
return
|
||||||
case "/metrics/auth":
|
case "/admin/config/get":
|
||||||
proxyHandler.MetricsAuthHandler(w, r)
|
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
|
||||||
return
|
return
|
||||||
case "/metrics/dashboard":
|
case "/admin/config/save":
|
||||||
proxyHandler.MetricsDashboardHandler(w, r)
|
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
|
||||||
return
|
return
|
||||||
|
case "/admin/auth":
|
||||||
|
proxyHandler.AuthHandler(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 遍历所有处理器
|
// 遍历所有处理器
|
||||||
@ -108,7 +122,7 @@ func main() {
|
|||||||
|
|
||||||
// 创建服务器
|
// 创建服务器
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: ":80",
|
Addr: ":3336",
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,7 +138,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
log.Println("Starting proxy server on :80")
|
log.Println("Starting proxy server on :3336")
|
||||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
log.Fatal("Error starting server:", err)
|
log.Fatal("Error starting server:", err)
|
||||||
}
|
}
|
||||||
|
72
web/static/css/main.css
Normal file
72
web/static/css/main.css
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 600px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background-color: #008CBA;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background-color: #007B9E;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message {
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background-color: #dff0d8;
|
||||||
|
color: #3c763d;
|
||||||
|
border: 1px solid #d6e9c6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: #f2dede;
|
||||||
|
color: #a94442;
|
||||||
|
border: 1px solid #ebccd1;
|
||||||
|
}
|
61
web/static/js/auth.js
Normal file
61
web/static/js/auth.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// 检查认证状态
|
||||||
|
function checkAuth() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/admin/login';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录函数
|
||||||
|
async function login() {
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('登录失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
window.location.href = '/admin/metrics';
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
window.location.href = '/admin/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取认证头
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示提示消息
|
||||||
|
function showToast(message, isError = false) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-end ${isError ? 'alert alert-error' : 'alert alert-success'}`;
|
||||||
|
toast.innerHTML = `<span>${message}</span>`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
66
web/static/js/config.js
Normal file
66
web/static/js/config.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
let editor = ace.edit("editor");
|
||||||
|
editor.setTheme("ace/theme/monokai");
|
||||||
|
editor.session.setMode("ace/mode/json");
|
||||||
|
editor.setOptions({
|
||||||
|
fontSize: "14px"
|
||||||
|
});
|
||||||
|
|
||||||
|
function showMessage(msg, isError = false) {
|
||||||
|
const msgDiv = document.getElementById('message');
|
||||||
|
msgDiv.textContent = msg;
|
||||||
|
msgDiv.className = isError ? 'error' : 'success';
|
||||||
|
msgDiv.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
msgDiv.style.display = 'none';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/metrics/config/get');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('加载配置失败');
|
||||||
|
}
|
||||||
|
const config = await response.json();
|
||||||
|
editor.setValue(JSON.stringify(config, null, 2), -1);
|
||||||
|
showMessage('配置已加载');
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(editor.getValue());
|
||||||
|
const response = await fetch('/metrics/config/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
showMessage(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJson() {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(editor.getValue());
|
||||||
|
editor.setValue(JSON.stringify(config, null, 2), -1);
|
||||||
|
showMessage('JSON已格式化');
|
||||||
|
} catch (error) {
|
||||||
|
showMessage('JSON格式错误: ' + error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始加载配置
|
||||||
|
loadConfig();
|
40
web/static/js/login.js
Normal file
40
web/static/js/login.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
async function login() {
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/metrics/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('登录失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
window.location.href = '/metrics/dashboard';
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(msg, isError = false) {
|
||||||
|
const msgDiv = document.getElementById('message');
|
||||||
|
msgDiv.textContent = msg;
|
||||||
|
msgDiv.className = isError ? 'error' : 'success';
|
||||||
|
msgDiv.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
msgDiv.style.display = 'none';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加回车键监听
|
||||||
|
document.getElementById('password').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
login();
|
||||||
|
}
|
||||||
|
});
|
105
web/static/js/metrics.js
Normal file
105
web/static/js/metrics.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
async function loadMetrics() {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/metrics/ui';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/metrics', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = '/metrics/ui';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('加载监控数据失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await response.json();
|
||||||
|
displayMetrics(metrics);
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayMetrics(metrics) {
|
||||||
|
const container = document.getElementById('metrics');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// 添加基本信息
|
||||||
|
addSection(container, '基本信息', {
|
||||||
|
'运行时间': metrics.uptime,
|
||||||
|
'总请求数': metrics.totalRequests,
|
||||||
|
'活跃请求数': metrics.activeRequests,
|
||||||
|
'错误请求数': metrics.totalErrors,
|
||||||
|
'总传输字节': formatBytes(metrics.totalBytes)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加状态码统计
|
||||||
|
addSection(container, '状态码统计', metrics.statusStats);
|
||||||
|
|
||||||
|
// 添加路径统计
|
||||||
|
addSection(container, '路径统计', metrics.pathStats);
|
||||||
|
|
||||||
|
// 添加来源统计
|
||||||
|
addSection(container, '来源统计', metrics.refererStats);
|
||||||
|
|
||||||
|
// 添加延迟统计
|
||||||
|
addSection(container, '延迟统计', {
|
||||||
|
'平均延迟': `${metrics.avgLatency}ms`,
|
||||||
|
'延迟分布': metrics.latencyBuckets
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSection(container, title, data) {
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'metrics-section';
|
||||||
|
|
||||||
|
const titleElem = document.createElement('h2');
|
||||||
|
titleElem.textContent = title;
|
||||||
|
section.appendChild(titleElem);
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'metrics-content';
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'metrics-item';
|
||||||
|
item.innerHTML = `<span class="key">${key}:</span> <span class="value">${value}</span>`;
|
||||||
|
content.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.appendChild(content);
|
||||||
|
container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(msg, isError = false) {
|
||||||
|
const msgDiv = document.getElementById('message');
|
||||||
|
if (!msgDiv) return;
|
||||||
|
|
||||||
|
msgDiv.textContent = msg;
|
||||||
|
msgDiv.className = isError ? 'error' : 'success';
|
||||||
|
msgDiv.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
msgDiv.style.display = 'none';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始加载监控数据
|
||||||
|
loadMetrics();
|
||||||
|
|
||||||
|
// 每30秒刷新一次数据
|
||||||
|
setInterval(loadMetrics, 30000);
|
73
web/templates/admin/config.html
Normal file
73
web/templates/admin/config.html
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{{define "Content"}}
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">配置管理</h2>
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<button class="btn btn-primary" onclick="saveConfig()">保存配置</button>
|
||||||
|
<button class="btn" onclick="loadConfig()">刷新配置</button>
|
||||||
|
<button class="btn" onclick="formatJson()">格式化JSON</button>
|
||||||
|
</div>
|
||||||
|
<div id="editor" class="h-[600px] w-full border border-base-300 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js"></script>
|
||||||
|
<script>
|
||||||
|
let editor = ace.edit("editor");
|
||||||
|
editor.setTheme("ace/theme/monokai");
|
||||||
|
editor.session.setMode("ace/mode/json");
|
||||||
|
editor.setOptions({
|
||||||
|
fontSize: "14px"
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/config/get', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('加载配置失败');
|
||||||
|
}
|
||||||
|
const config = await response.json();
|
||||||
|
editor.setValue(JSON.stringify(config, null, 2), -1);
|
||||||
|
showToast('配置已加载');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(editor.getValue());
|
||||||
|
const response = await fetch('/admin/config/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
showToast(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJson() {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(editor.getValue());
|
||||||
|
editor.setValue(JSON.stringify(config, null, 2), -1);
|
||||||
|
showToast('JSON已格式化');
|
||||||
|
} catch (error) {
|
||||||
|
showToast('JSON格式错误: ' + error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始加载配置
|
||||||
|
loadConfig();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
1
web/templates/admin/layout.html
Normal file
1
web/templates/admin/layout.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
216
web/templates/admin/metrics.html
Normal file
216
web/templates/admin/metrics.html
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
{{define "Content"}}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">基础指标</h2>
|
||||||
|
<div class="stats stats-vertical shadow">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">运行时间</div>
|
||||||
|
<div class="stat-value text-lg" id="uptime"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">当前活跃请求</div>
|
||||||
|
<div class="stat-value text-lg" id="activeRequests"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">总请求数</div>
|
||||||
|
<div class="stat-value text-lg" id="totalRequests"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">错误数</div>
|
||||||
|
<div class="stat-value text-lg" id="totalErrors"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">系统指标</h2>
|
||||||
|
<div class="stats stats-vertical shadow">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Goroutine数量</div>
|
||||||
|
<div class="stat-value text-lg" id="numGoroutine"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">内存使用</div>
|
||||||
|
<div class="stat-value text-lg" id="memoryUsage"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">平均响应时间</div>
|
||||||
|
<div class="stat-value text-lg" id="avgResponseTime"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">每秒请求数</div>
|
||||||
|
<div class="stat-value text-lg" id="requestsPerSecond"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">状态码统计</h2>
|
||||||
|
<div id="statusCodes" class="grid grid-cols-2 md:grid-cols-5 gap-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">热门路径 (Top 10)</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>路径</th>
|
||||||
|
<th>请求数</th>
|
||||||
|
<th>错误数</th>
|
||||||
|
<th>平均延迟</th>
|
||||||
|
<th>传输大小</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="topPaths"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">最近请求</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>路径</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>延迟</th>
|
||||||
|
<th>大小</th>
|
||||||
|
<th>客户端IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recentRequests"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLatency(nanoseconds) {
|
||||||
|
if (nanoseconds < 1000) {
|
||||||
|
return nanoseconds + ' ns';
|
||||||
|
} else if (nanoseconds < 1000000) {
|
||||||
|
return (nanoseconds / 1000).toFixed(2) + ' µs';
|
||||||
|
} else if (nanoseconds < 1000000000) {
|
||||||
|
return (nanoseconds / 1000000).toFixed(2) + ' ms';
|
||||||
|
} else {
|
||||||
|
return (nanoseconds / 1000000000).toFixed(2) + ' s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMetrics(data) {
|
||||||
|
// 更新基础指标
|
||||||
|
document.getElementById('uptime').textContent = data.uptime;
|
||||||
|
document.getElementById('activeRequests').textContent = data.active_requests;
|
||||||
|
document.getElementById('totalRequests').textContent = data.total_requests;
|
||||||
|
document.getElementById('totalErrors').textContent = data.total_errors;
|
||||||
|
|
||||||
|
// 更新系统指标
|
||||||
|
document.getElementById('numGoroutine').textContent = data.num_goroutine;
|
||||||
|
document.getElementById('memoryUsage').textContent = data.memory_usage;
|
||||||
|
document.getElementById('avgResponseTime').textContent = data.avg_response_time;
|
||||||
|
document.getElementById('requestsPerSecond').textContent = data.requests_per_second.toFixed(2);
|
||||||
|
|
||||||
|
// 更新状态码统计
|
||||||
|
const statusCodesHtml = Object.entries(data.status_code_stats || {})
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([status, count]) => {
|
||||||
|
const firstDigit = status.charAt(0);
|
||||||
|
let color = 'success';
|
||||||
|
if (firstDigit === '4') color = 'warning';
|
||||||
|
if (firstDigit === '5') color = 'error';
|
||||||
|
if (firstDigit === '3') color = 'info';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="stat shadow">
|
||||||
|
<div class="stat-title">状态码 ${status}</div>
|
||||||
|
<div class="stat-value text-${color}">${count}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
document.getElementById('statusCodes').innerHTML = statusCodesHtml;
|
||||||
|
|
||||||
|
// 更新热门路径
|
||||||
|
const topPathsHtml = (data.top_paths || []).map(path => `
|
||||||
|
<tr>
|
||||||
|
<td>${path.path}</td>
|
||||||
|
<td>${path.request_count}</td>
|
||||||
|
<td>${path.error_count}</td>
|
||||||
|
<td>${path.avg_latency}</td>
|
||||||
|
<td>${formatBytes(path.bytes_transferred)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
document.getElementById('topPaths').innerHTML = topPathsHtml;
|
||||||
|
|
||||||
|
// 更新最近请求
|
||||||
|
const recentRequestsHtml = (data.recent_requests || []).map(req => {
|
||||||
|
const statusClass = {
|
||||||
|
2: 'success',
|
||||||
|
3: 'info',
|
||||||
|
4: 'warning',
|
||||||
|
5: 'error'
|
||||||
|
}[Math.floor(req.Status/100)] || '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${formatDate(req.Time)}</td>
|
||||||
|
<td class="max-w-xs truncate">${req.Path}</td>
|
||||||
|
<td><div class="badge badge-${statusClass}">${req.Status}</div></td>
|
||||||
|
<td>${formatLatency(req.Latency)}</td>
|
||||||
|
<td>${formatBytes(req.BytesSent)}</td>
|
||||||
|
<td>${req.ClientIP}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
document.getElementById('recentRequests').innerHTML = recentRequestsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMetrics() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/metrics', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('加载监控数据失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await response.json();
|
||||||
|
updateMetrics(metrics);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始加载监控数据
|
||||||
|
loadMetrics();
|
||||||
|
|
||||||
|
// 每5秒刷新一次数据
|
||||||
|
setInterval(loadMetrics, 5000);
|
||||||
|
</script>
|
||||||
|
{{end}}
|
Loading…
x
Reference in New Issue
Block a user