From 33d6a51416db59d77e2a64196a1d3e28dcfa5dab Mon Sep 17 00:00:00 2001 From: wood chen Date: Sat, 15 Feb 2025 11:44:09 +0800 Subject: [PATCH] refactor(web): Migrate to modern web frontend and simplify admin routes - Remove legacy static files, templates, and JavaScript - Update main.go to serve SPA-style web application - Modify admin route handling to support client-side routing - Simplify configuration and metrics API endpoints - Remove server-side template rendering in favor of static file serving - Update Dockerfile and GitHub Actions to build web frontend --- .github/workflows/docker-build.yml | 35 +- .gitignore | 4 + Dockerfile | 3 +- internal/config/config.go | 71 +- internal/config/types.go | 49 +- internal/constants/constants.go | 89 +- internal/handler/auth.go | 46 +- internal/handler/config.go | 7 +- internal/handler/proxy.go | 50 +- internal/metrics/collector.go | 8 - internal/monitor/feishu.go | 81 - internal/monitor/monitor.go | 294 -- main.go | 111 +- web/.eslintrc.json | 8 + web/.gitignore | 41 + web/README.md | 36 + web/app/dashboard/config/page.tsx | 167 + web/app/dashboard/layout.tsx | 67 + web/app/dashboard/page.tsx | 311 ++ web/app/favicon.ico | Bin 0 -> 25931 bytes web/app/globals.css | 72 + web/app/layout.tsx | 26 + web/app/login/page.tsx | 89 + web/app/page.tsx | 26 + web/components.json | 21 + web/components/nav.tsx | 59 + web/components/ui/button.tsx | 56 + web/components/ui/card.tsx | 75 + web/components/ui/input.tsx | 23 + web/components/ui/toast.tsx | 127 + web/components/ui/toaster.tsx | 35 + web/components/ui/use-toast.ts | 189 + web/eslint.config.mjs | 16 + web/lib/utils.ts | 6 + web/next.config.js | 23 + web/package-lock.json | 6186 ++++++++++++++++++++++++++++ web/package.json | 34 + web/postcss.config.mjs | 8 + web/public/file.svg | 1 + web/public/globe.svg | 1 + web/public/next.svg | 1 + web/public/vercel.svg | 1 + web/public/window.svg | 1 + web/static/css/main.css | 72 - web/static/js/auth.js | 61 - web/static/js/config.js | 66 - web/static/js/login.js | 40 - web/static/js/metrics.js | 105 - web/tailwind.config.ts | 62 + web/templates/admin/config.html | 73 - web/templates/admin/layout.html | 42 - web/templates/admin/login.html | 19 - web/templates/admin/metrics.html | 216 - web/tsconfig.json | 27 + 54 files changed, 8019 insertions(+), 1318 deletions(-) delete mode 100644 internal/monitor/feishu.go delete mode 100644 internal/monitor/monitor.go create mode 100644 web/.eslintrc.json create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/app/dashboard/config/page.tsx create mode 100644 web/app/dashboard/layout.tsx create mode 100644 web/app/dashboard/page.tsx create mode 100644 web/app/favicon.ico create mode 100644 web/app/globals.css create mode 100644 web/app/layout.tsx create mode 100644 web/app/login/page.tsx create mode 100644 web/app/page.tsx create mode 100644 web/components.json create mode 100644 web/components/nav.tsx create mode 100644 web/components/ui/button.tsx create mode 100644 web/components/ui/card.tsx create mode 100644 web/components/ui/input.tsx create mode 100644 web/components/ui/toast.tsx create mode 100644 web/components/ui/toaster.tsx create mode 100644 web/components/ui/use-toast.ts create mode 100644 web/eslint.config.mjs create mode 100644 web/lib/utils.ts create mode 100644 web/next.config.js create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.mjs create mode 100644 web/public/file.svg create mode 100644 web/public/globe.svg create mode 100644 web/public/next.svg create mode 100644 web/public/vercel.svg create mode 100644 web/public/window.svg delete mode 100644 web/static/css/main.css delete mode 100644 web/static/js/auth.js delete mode 100644 web/static/js/config.js delete mode 100644 web/static/js/login.js delete mode 100644 web/static/js/metrics.js create mode 100644 web/tailwind.config.ts delete mode 100644 web/templates/admin/config.html delete mode 100644 web/templates/admin/layout.html delete mode 100644 web/templates/admin/login.html delete mode 100644 web/templates/admin/metrics.html create mode 100644 web/tsconfig.json diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 7dda14e..e95e68c 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -8,7 +8,34 @@ on: branches: [ main ] jobs: - build: + build-web: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + working-directory: web + run: npm ci + + - name: Build web + working-directory: web + run: npm run build + + - name: Upload web artifact + uses: actions/upload-artifact@v4 + with: + name: web-out + path: web/out + + build-backend: runs-on: ubuntu-latest strategy: matrix: @@ -42,7 +69,7 @@ jobs: path: proxy-go-${{ matrix.arch }} docker: - needs: build + needs: [build-web, build-backend] runs-on: ubuntu-latest steps: - name: Checkout repository @@ -63,14 +90,14 @@ jobs: username: woodchen password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Create Docker build context run: | mkdir -p docker-context cp Dockerfile docker-context/ cp proxy-go-amd64/proxy-go-amd64 docker-context/proxy-go.amd64 cp proxy-go-arm64/proxy-go-arm64 docker-context/proxy-go.arm64 - cp -r web docker-context/ + mkdir -p docker-context/web/out + cp -r web-out/* docker-context/web/out/ - name: Build and push Docker images uses: docker/build-push-action@v6 diff --git a/.gitignore b/.gitignore index a4e6d90..6998f73 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ vendor/ .vscode/ *.swp *.swo +web/node_modules/ +web/dist/ +data/config.json +data/config.json diff --git a/Dockerfile b/Dockerfile index 27293fd..2bde8aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,11 @@ +# 构建后端 FROM alpine:latest ARG TARGETARCH WORKDIR /app COPY proxy-go.${TARGETARCH} /app/proxy-go -COPY web /app/web +COPY web/out /app/web/out RUN mkdir -p /app/data && \ chmod +x /app/proxy-go && \ diff --git a/internal/config/config.go b/internal/config/config.go index 90d7b8f..32bd125 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,10 +3,26 @@ package config import ( "encoding/json" "os" + "sync" "sync/atomic" "time" ) +// Config 配置结构体 +type configImpl struct { + sync.RWMutex + Config + // 配置更新回调函数 + onConfigUpdate []func(*Config) +} + +var ( + instance *configImpl + once sync.Once + configCallbacks []func(*Config) + callbackMutex sync.RWMutex +) + type ConfigManager struct { config atomic.Value configPath string @@ -26,18 +42,63 @@ func (cm *ConfigManager) watchConfig() { } } +// Load 加载配置 func Load(path string) (*Config, error) { + var err error + once.Do(func() { + instance = &configImpl{} + err = instance.reload(path) + }) + return &instance.Config, err +} + +// RegisterUpdateCallback 注册配置更新回调函数 +func RegisterUpdateCallback(callback func(*Config)) { + callbackMutex.Lock() + defer callbackMutex.Unlock() + configCallbacks = append(configCallbacks, callback) +} + +// TriggerCallbacks 触发所有回调 +func TriggerCallbacks(cfg *Config) { + callbackMutex.RLock() + defer callbackMutex.RUnlock() + for _, callback := range configCallbacks { + callback(cfg) + } +} + +// Update 更新配置并触发回调 +func (c *configImpl) Update(newConfig *Config) { + c.Lock() + defer c.Unlock() + + // 更新配置 + c.MAP = newConfig.MAP + c.Compression = newConfig.Compression + c.FixedPaths = newConfig.FixedPaths + c.Metrics = newConfig.Metrics + + // 触发回调 + for _, callback := range c.onConfigUpdate { + callback(newConfig) + } +} + +// reload 重新加载配置文件 +func (c *configImpl) reload(path string) error { data, err := os.ReadFile(path) if err != nil { - return nil, err + return err } - var config Config - if err := json.Unmarshal(data, &config); err != nil { - return nil, err + var newConfig Config + if err := json.Unmarshal(data, &newConfig); err != nil { + return err } - return &config, nil + c.Update(&newConfig) + return nil } func (cm *ConfigManager) loadConfig() error { diff --git a/internal/config/types.go b/internal/config/types.go index b900677..c370430 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -3,7 +3,6 @@ package config import ( "encoding/json" "strings" - "time" ) type Config struct { @@ -36,52 +35,10 @@ type FixedPathConfig struct { TargetURL string `json:"TargetURL"` } +// MetricsConfig 监控配置 type MetricsConfig struct { - Password string `json:"Password"` - TokenExpiry int `json:"TokenExpiry"` - FeishuWebhook string `json:"FeishuWebhook"` - // 监控告警配置 - Alert struct { - WindowSize int `json:"WindowSize"` // 监控窗口数量 - WindowInterval time.Duration `json:"WindowInterval"` // 每个窗口时间长度 - DedupeWindow time.Duration `json:"DedupeWindow"` // 告警去重时间窗口 - MinRequests int64 `json:"MinRequests"` // 触发告警的最小请求数 - ErrorRate float64 `json:"ErrorRate"` // 错误率告警阈值 - AlertInterval time.Duration `json:"AlertInterval"` // 告警间隔时间 - } `json:"Alert"` - // 延迟告警配置 - Latency struct { - SmallFileSize int64 `json:"SmallFileSize"` // 小文件阈值 - MediumFileSize int64 `json:"MediumFileSize"` // 中等文件阈值 - LargeFileSize int64 `json:"LargeFileSize"` // 大文件阈值 - SmallLatency time.Duration `json:"SmallLatency"` // 小文件最大延迟 - MediumLatency time.Duration `json:"MediumLatency"` // 中等文件最大延迟 - LargeLatency time.Duration `json:"LargeLatency"` // 大文件最大延迟 - HugeLatency time.Duration `json:"HugeLatency"` // 超大文件最大延迟 - } `json:"Latency"` - // 性能监控配置 - Performance struct { - MaxRequestsPerMinute int64 `json:"MaxRequestsPerMinute"` - MaxBytesPerMinute int64 `json:"MaxBytesPerMinute"` - MaxSaveInterval time.Duration `json:"MaxSaveInterval"` - } `json:"Performance"` - // 加载配置 - Load struct { - RetryCount int `json:"retry_count"` - RetryInterval time.Duration `json:"retry_interval"` - Timeout time.Duration `json:"timeout"` - } `json:"load"` - // 保存配置 - Save struct { - MinInterval time.Duration `json:"min_interval"` - MaxInterval time.Duration `json:"max_interval"` - DefaultInterval time.Duration `json:"default_interval"` - } `json:"save"` - // 验证配置 - Validation struct { - MaxErrorRate float64 `json:"max_error_rate"` - MaxDataDeviation float64 `json:"max_data_deviation"` - } `json:"validation"` + Password string `json:"Password"` // 管理密码 + TokenExpiry int `json:"TokenExpiry"` // Token过期时间(秒) } // 添加一个辅助方法来处理字符串到 PathConfig 的转换 diff --git a/internal/constants/constants.go b/internal/constants/constants.go index fe2d4da..ac0c45c 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -15,99 +15,12 @@ var ( MaxPathsStored = 1000 // 最大存储路径数 MaxRecentLogs = 1000 // 最大最近日志数 - // 监控告警相关 - AlertWindowSize = 12 // 监控窗口数量 - AlertWindowInterval = 5 * time.Minute // 每个窗口时间长度 - AlertDedupeWindow = 15 * time.Minute // 告警去重时间窗口 - AlertNotifyInterval = 24 * time.Hour // 告警通知间隔 - MinRequestsForAlert int64 = 10 // 触发告警的最小请求数 - ErrorRateThreshold = 0.8 // 错误率告警阈值 - - // 延迟告警阈值 - SmallFileSize int64 = 1 * MB // 小文件阈值 - MediumFileSize int64 = 10 * MB // 中等文件阈值 - LargeFileSize int64 = 100 * MB // 大文件阈值 - - SmallFileLatency = 5 * time.Second // 小文件最大延迟 - MediumFileLatency = 10 * time.Second // 中等文件最大延迟 - LargeFileLatency = 50 * time.Second // 大文件最大延迟 - HugeFileLatency = 300 * time.Second // 超大文件最大延迟 (5分钟) - // 单位常量 KB int64 = 1024 MB int64 = 1024 * KB - - // 数据验证相关 - MaxErrorRate = 0.8 // 最大错误率 - MaxDataDeviation = 0.01 // 最大数据偏差(1%) - - // 性能监控阈值 - MaxRequestsPerMinute int64 = 1000 // 每分钟最大请求数 - MaxBytesPerMinute int64 = 100 * 1024 * 1024 // 每分钟最大流量 (100MB) - MaxSaveInterval = 15 * time.Minute // 最大保存间隔 ) // UpdateFromConfig 从配置文件更新常量 func UpdateFromConfig(cfg *config.Config) { - // 告警配置 - if cfg.Metrics.Alert.WindowSize > 0 { - AlertWindowSize = cfg.Metrics.Alert.WindowSize - } - if cfg.Metrics.Alert.WindowInterval > 0 { - AlertWindowInterval = cfg.Metrics.Alert.WindowInterval - } - if cfg.Metrics.Alert.DedupeWindow > 0 { - AlertDedupeWindow = cfg.Metrics.Alert.DedupeWindow - } - if cfg.Metrics.Alert.MinRequests > 0 { - MinRequestsForAlert = cfg.Metrics.Alert.MinRequests - } - if cfg.Metrics.Alert.ErrorRate > 0 { - ErrorRateThreshold = cfg.Metrics.Alert.ErrorRate - } - if cfg.Metrics.Alert.AlertInterval > 0 { - AlertNotifyInterval = cfg.Metrics.Alert.AlertInterval - } - - // 延迟告警配置 - if cfg.Metrics.Latency.SmallFileSize > 0 { - SmallFileSize = cfg.Metrics.Latency.SmallFileSize - } - if cfg.Metrics.Latency.MediumFileSize > 0 { - MediumFileSize = cfg.Metrics.Latency.MediumFileSize - } - if cfg.Metrics.Latency.LargeFileSize > 0 { - LargeFileSize = cfg.Metrics.Latency.LargeFileSize - } - if cfg.Metrics.Latency.SmallLatency > 0 { - SmallFileLatency = cfg.Metrics.Latency.SmallLatency - } - if cfg.Metrics.Latency.MediumLatency > 0 { - MediumFileLatency = cfg.Metrics.Latency.MediumLatency - } - if cfg.Metrics.Latency.LargeLatency > 0 { - LargeFileLatency = cfg.Metrics.Latency.LargeLatency - } - if cfg.Metrics.Latency.HugeLatency > 0 { - HugeFileLatency = cfg.Metrics.Latency.HugeLatency - } - - // 数据验证相关 - if cfg.Metrics.Validation.MaxErrorRate > 0 { - MaxErrorRate = cfg.Metrics.Validation.MaxErrorRate - } - if cfg.Metrics.Validation.MaxDataDeviation > 0 { - MaxDataDeviation = cfg.Metrics.Validation.MaxDataDeviation - } - - // 性能监控阈值 - if cfg.Metrics.Performance.MaxRequestsPerMinute > 0 { - MaxRequestsPerMinute = cfg.Metrics.Performance.MaxRequestsPerMinute - } - if cfg.Metrics.Performance.MaxBytesPerMinute > 0 { - MaxBytesPerMinute = cfg.Metrics.Performance.MaxBytesPerMinute - } - if cfg.Metrics.Performance.MaxSaveInterval > 0 { - MaxSaveInterval = cfg.Metrics.Performance.MaxSaveInterval - } + // 空实现,不再需要更新监控相关配置 } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 853fa1f..410c484 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "log" "net/http" "strings" "sync" @@ -64,6 +65,28 @@ func (am *authManager) cleanExpiredTokens() { } } +// CheckAuth 检查认证令牌是否有效 +func (h *ProxyHandler) CheckAuth(token string) bool { + return h.auth.validateToken(token) +} + +// LogoutHandler 处理退出登录请求 +func (h *ProxyHandler) LogoutHandler(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 ") + h.auth.tokens.Delete(token) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "已退出登录", + }) +} + // AuthMiddleware 认证中间件 func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -86,19 +109,29 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { // AuthHandler 处理认证请求 func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { + log.Printf("[Auth] 方法不允许: %s", r.Method) 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 { + // 解析表单数据 + if err := r.ParseForm(); err != nil { + log.Printf("[Auth] 表单解析失败: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } - if req.Password != h.config.Metrics.Password { + password := r.FormValue("password") + log.Printf("[Auth] 收到登录请求,密码长度: %d", len(password)) + + if password == "" { + log.Printf("[Auth] 密码为空") + http.Error(w, "Password is required", http.StatusBadRequest) + return + } + + if password != h.config.Metrics.Password { + log.Printf("[Auth] 密码错误") http.Error(w, "Invalid password", http.StatusUnauthorized) return } @@ -106,7 +139,10 @@ func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { token := h.auth.generateToken() h.auth.addToken(token, time.Duration(h.config.Metrics.TokenExpiry)*time.Second) + log.Printf("[Auth] 登录成功,生成令牌") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{ "token": token, }) diff --git a/internal/handler/config.go b/internal/handler/config.go index 55a55d5..f04db03 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -24,11 +24,9 @@ func NewConfigHandler(cfg *config.Config) *ConfigHandler { // 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": + case "/admin/api/config/get": h.handleGetConfig(w, r) - case "/metrics/config/save": + case "/admin/api/config/save": h.handleSaveConfig(w, r) default: http.NotFound(w, r) @@ -96,6 +94,7 @@ func (h *ConfigHandler) handleSaveConfig(w http.ResponseWriter, r *http.Request) // 更新运行时配置 *h.config = newConfig + config.TriggerCallbacks(h.config) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"message": "配置已更新并生效"}`)) diff --git a/internal/handler/proxy.go b/internal/handler/proxy.go index dfa6cac..3778600 100644 --- a/internal/handler/proxy.go +++ b/internal/handler/proxy.go @@ -237,34 +237,33 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler { } transport := &http.Transport{ - DialContext: dialer.DialContext, - MaxIdleConns: 300, // 增加最大空闲连接数 - MaxIdleConnsPerHost: 50, // 增加每个主机的最大空闲连接数 - IdleConnTimeout: idleConnTimeout, - TLSHandshakeTimeout: tlsHandshakeTimeout, - ExpectContinueTimeout: 1 * time.Second, - MaxConnsPerHost: 100, // 增加每个主机的最大连接数 - DisableKeepAlives: false, - DisableCompression: false, - ForceAttemptHTTP2: true, - WriteBufferSize: 64 * 1024, - ReadBufferSize: 64 * 1024, - ResponseHeaderTimeout: backendServTimeout, - // HTTP/2 特定设置 - MaxResponseHeaderBytes: 64 * 1024, // 增加最大响应头大小 + DialContext: dialer.DialContext, + MaxIdleConns: 300, + MaxIdleConnsPerHost: 50, + IdleConnTimeout: idleConnTimeout, + TLSHandshakeTimeout: tlsHandshakeTimeout, + ExpectContinueTimeout: 1 * time.Second, + MaxConnsPerHost: 100, + DisableKeepAlives: false, + DisableCompression: false, + ForceAttemptHTTP2: true, + WriteBufferSize: 64 * 1024, + ReadBufferSize: 64 * 1024, + ResponseHeaderTimeout: backendServTimeout, + MaxResponseHeaderBytes: 64 * 1024, } // 设置HTTP/2传输配置 http2Transport, err := http2.ConfigureTransports(transport) if err == nil && http2Transport != nil { - http2Transport.ReadIdleTimeout = 10 * time.Second // HTTP/2读取超时 - http2Transport.PingTimeout = 5 * time.Second // HTTP/2 ping超时 - http2Transport.AllowHTTP = false // 只允许HTTPS - http2Transport.MaxReadFrameSize = 32 * 1024 // 增加帧大小 - http2Transport.StrictMaxConcurrentStreams = true // 严格遵守最大并发流 + http2Transport.ReadIdleTimeout = 10 * time.Second + http2Transport.PingTimeout = 5 * time.Second + http2Transport.AllowHTTP = false + http2Transport.MaxReadFrameSize = 32 * 1024 + http2Transport.StrictMaxConcurrentStreams = true } - return &ProxyHandler{ + handler := &ProxyHandler{ pathMap: cfg.MAP, client: &http.Client{ Transport: transport, @@ -292,6 +291,15 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler { } }, } + + // 注册配置更新回调 + config.RegisterUpdateCallback(func(newCfg *config.Config) { + handler.pathMap = newCfg.MAP + handler.config = newCfg + log.Printf("[Config] 配置已更新并生效") + }) + + return handler } // SetErrorHandler 允许自定义错误处理函数 diff --git a/internal/metrics/collector.go b/internal/metrics/collector.go index a958c5b..b5c2cf1 100644 --- a/internal/metrics/collector.go +++ b/internal/metrics/collector.go @@ -56,14 +56,6 @@ func InitCollector(config *config.Config) error { // 初始化监控器 globalCollector.monitor = monitor.NewMonitor(globalCollector) - // 如果配置了飞书webhook,则启用飞书告警 - if config.Metrics.FeishuWebhook != "" { - globalCollector.monitor.AddHandler( - monitor.NewFeishuHandler(config.Metrics.FeishuWebhook), - ) - log.Printf("Feishu alert enabled") - } - // 初始化对象池 globalCollector.statsPool = sync.Pool{ New: func() interface{} { diff --git a/internal/monitor/feishu.go b/internal/monitor/feishu.go deleted file mode 100644 index e945d24..0000000 --- a/internal/monitor/feishu.go +++ /dev/null @@ -1,81 +0,0 @@ -package monitor - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "sync" - "time" -) - -type FeishuHandler struct { - webhookURL string - client *http.Client - cardPool sync.Pool -} - -func NewFeishuHandler(webhookURL string) *FeishuHandler { - h := &FeishuHandler{ - webhookURL: webhookURL, - client: &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - }, - }, - } - h.cardPool = sync.Pool{ - New: func() interface{} { - return &FeishuCard{} - }, - } - return h -} - -type FeishuCard struct { - MsgType string `json:"msg_type"` - Card struct { - Header struct { - Title struct { - Content string `json:"content"` - Tag string `json:"tag"` - } `json:"title"` - } `json:"header"` - Elements []interface{} `json:"elements"` - } `json:"card"` -} - -func (h *FeishuHandler) HandleAlert(alert Alert) { - card := h.cardPool.Get().(*FeishuCard) - - // 设置标题 - card.Card.Header.Title.Tag = "plain_text" - card.Card.Header.Title.Content = fmt.Sprintf("[%s] 监控告警", alert.Level) - - // 添加告警内容 - content := map[string]interface{}{ - "tag": "div", - "text": map[string]interface{}{ - "content": fmt.Sprintf("**告警时间**: %s\n**告警内容**: %s", - alert.Time.Format("2006-01-02 15:04:05"), - alert.Message), - "tag": "lark_md", - }, - } - - card.Card.Elements = []interface{}{content} - - // 发送请求 - payload, _ := json.Marshal(card) - resp, err := h.client.Post(h.webhookURL, "application/json", bytes.NewBuffer(payload)) - if err != nil { - fmt.Printf("Failed to send Feishu alert: %v\n", err) - return - } - defer resp.Body.Close() - - h.cardPool.Put(card) -} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go deleted file mode 100644 index 00fc2aa..0000000 --- a/internal/monitor/monitor.go +++ /dev/null @@ -1,294 +0,0 @@ -package monitor - -import ( - "fmt" - "log" - "proxy-go/internal/constants" - "proxy-go/internal/interfaces" - "sync" - "sync/atomic" - "time" -) - -type AlertLevel string - -const ( - AlertLevelError AlertLevel = "ERROR" - AlertLevelWarn AlertLevel = "WARN" - AlertLevelInfo AlertLevel = "INFO" -) - -type Alert struct { - Level AlertLevel - Message string - Time time.Time -} - -type AlertHandler interface { - HandleAlert(alert Alert) -} - -// 日志告警处理器 -type LogAlertHandler struct { - logger *log.Logger -} - -type ErrorStats struct { - totalRequests atomic.Int64 - errorRequests atomic.Int64 - timestamp time.Time -} - -type TransferStats struct { - bytes atomic.Int64 - duration atomic.Int64 - timestamp time.Time -} - -type Monitor struct { - alerts chan Alert - handlers []AlertHandler - dedup sync.Map - lastNotify sync.Map - errorWindow [12]ErrorStats - currentWindow atomic.Int32 - transferWindow [12]TransferStats - currentTWindow atomic.Int32 - collector interfaces.MetricsCollector -} - -func NewMonitor(collector interfaces.MetricsCollector) *Monitor { - m := &Monitor{ - alerts: make(chan Alert, 100), - handlers: make([]AlertHandler, 0), - collector: collector, - } - - // 初始化第一个窗口 - m.errorWindow[0] = ErrorStats{timestamp: time.Now()} - m.transferWindow[0] = TransferStats{timestamp: time.Now()} - - // 添加默认的日志处理器 - m.AddHandler(&LogAlertHandler{ - logger: log.New(log.Writer(), "[ALERT] ", log.LstdFlags), - }) - - // 启动告警处理 - go m.processAlerts() - - // 启动窗口清理 - go m.cleanupWindows() - - return m -} - -func (m *Monitor) AddHandler(handler AlertHandler) { - m.handlers = append(m.handlers, handler) -} - -func (m *Monitor) processAlerts() { - for alert := range m.alerts { - // 检查是否在去重时间窗口内 - key := fmt.Sprintf("%s:%s", alert.Level, alert.Message) - if _, ok := m.dedup.LoadOrStore(key, time.Now()); ok { - continue - } - - // 检查是否在通知间隔内 - notifyKey := fmt.Sprintf("notify:%s", alert.Level) - if lastTime, ok := m.lastNotify.Load(notifyKey); ok { - if time.Since(lastTime.(time.Time)) < constants.AlertNotifyInterval { - continue - } - } - m.lastNotify.Store(notifyKey, time.Now()) - - for _, handler := range m.handlers { - handler.HandleAlert(alert) - } - } -} - -func (m *Monitor) CheckMetrics(stats map[string]interface{}) { - currentIdx := int(m.currentWindow.Load()) - window := &m.errorWindow[currentIdx] - - if time.Since(window.timestamp) >= constants.AlertWindowInterval { - // 轮转到下一个窗口 - nextIdx := (currentIdx + 1) % constants.AlertWindowSize - m.errorWindow[nextIdx] = ErrorStats{timestamp: time.Now()} - m.currentWindow.Store(int32(nextIdx)) - } - - var recentErrors, recentRequests int64 - now := time.Now() - for i := 0; i < constants.AlertWindowSize; i++ { - idx := (currentIdx - i + constants.AlertWindowSize) % constants.AlertWindowSize - w := &m.errorWindow[idx] - - if now.Sub(w.timestamp) <= constants.AlertDedupeWindow { - recentErrors += w.errorRequests.Load() - recentRequests += w.totalRequests.Load() - } - } - - // 检查错误率 - if recentRequests >= constants.MinRequestsForAlert { - errorRate := float64(recentErrors) / float64(recentRequests) - if errorRate > constants.ErrorRateThreshold { - m.alerts <- Alert{ - Level: AlertLevelError, - Message: fmt.Sprintf("最近%d分钟内错误率过高: %.2f%% (错误请求: %d, 总请求: %d)", - int(constants.AlertDedupeWindow.Minutes()), - errorRate*100, recentErrors, recentRequests), - Time: time.Now(), - } - } - } - - // 检查数据一致性 - if err := m.collector.CheckDataConsistency(); err != nil { - m.recordAlert("数据一致性告警", err.Error()) - } - - // 检查错误率 - totalReqs := stats["total_requests"].(int64) - totalErrs := stats["total_errors"].(int64) - if totalReqs > 0 && float64(totalErrs)/float64(totalReqs) > constants.MaxErrorRate { - m.recordAlert("错误率告警", fmt.Sprintf("错误率超过阈值: %.2f%%", float64(totalErrs)/float64(totalReqs)*100)) - } - - // 检查数据保存 - if lastSaveTime := m.collector.GetLastSaveTime(); time.Since(lastSaveTime) > constants.MaxSaveInterval*2 { - m.recordAlert("数据保存告警", "数据保存间隔过长") - } -} - -func (m *Monitor) CheckLatency(latency time.Duration, bytes int64) { - // 更新传输速率窗口 - currentIdx := int(m.currentTWindow.Load()) - window := &m.transferWindow[currentIdx] - - if time.Since(window.timestamp) >= constants.AlertWindowInterval { - // 轮转到下一个窗口 - nextIdx := (currentIdx + 1) % constants.AlertWindowSize - m.transferWindow[nextIdx] = TransferStats{timestamp: time.Now()} - m.currentTWindow.Store(int32(nextIdx)) - currentIdx = nextIdx - window = &m.transferWindow[currentIdx] - } - - window.bytes.Add(bytes) - window.duration.Add(int64(latency)) - - // 计算最近15分钟的平均传输速率 - var totalBytes, totalDuration int64 - now := time.Now() - for i := 0; i < constants.AlertWindowSize; i++ { - idx := (currentIdx - i + constants.AlertWindowSize) % constants.AlertWindowSize - w := &m.transferWindow[idx] - - if now.Sub(w.timestamp) <= constants.AlertDedupeWindow { - totalBytes += w.bytes.Load() - - totalDuration += w.duration.Load() - } - } - - if totalDuration > 0 { - avgRate := float64(totalBytes) / (float64(totalDuration) / float64(time.Second)) - - // 根据文件大小计算最小速率要求 - var ( - fileSize int64 - maxLatency time.Duration - ) - switch { - case bytes < constants.SmallFileSize: - fileSize = constants.SmallFileSize - maxLatency = constants.SmallFileLatency - case bytes < constants.MediumFileSize: - fileSize = constants.MediumFileSize - maxLatency = constants.MediumFileLatency - case bytes < constants.LargeFileSize: - fileSize = constants.LargeFileSize - maxLatency = constants.LargeFileLatency - default: - fileSize = bytes - maxLatency = constants.HugeFileLatency - } - - // 计算最小速率 = 文件大小 / 最大允许延迟 - minRate := float64(fileSize) / maxLatency.Seconds() - - // 只有当15分钟内的平均传输速率低于阈值时才告警 - if avgRate < minRate { - m.alerts <- Alert{ - Level: AlertLevelWarn, - Message: fmt.Sprintf( - "最近%d分钟内平均传输速率过低: %.2f MB/s (最低要求: %.2f MB/s, 基准文件大小: %s, 最大延迟: %s)", - int(constants.AlertDedupeWindow.Minutes()), - avgRate/float64(constants.MB), - minRate/float64(constants.MB), - formatBytes(fileSize), - maxLatency, - ), - Time: time.Now(), - } - } - } -} - -// 日志处理器实现 -func (h *LogAlertHandler) HandleAlert(alert Alert) { - h.logger.Printf("[%s] %s", alert.Level, alert.Message) -} - -func (m *Monitor) RecordRequest() { - currentIdx := int(m.currentWindow.Load()) - window := &m.errorWindow[currentIdx] - window.totalRequests.Add(1) -} - -func (m *Monitor) RecordError() { - currentIdx := int(m.currentWindow.Load()) - window := &m.errorWindow[currentIdx] - window.errorRequests.Add(1) -} - -// 格式化字节大小 -func formatBytes(bytes int64) string { - switch { - case bytes >= constants.MB: - return fmt.Sprintf("%.2f MB", float64(bytes)/float64(constants.MB)) - case bytes >= constants.KB: - return fmt.Sprintf("%.2f KB", float64(bytes)/float64(constants.KB)) - default: - return fmt.Sprintf("%d Bytes", bytes) - } -} - -// 添加窗口清理 -func (m *Monitor) cleanupWindows() { - ticker := time.NewTicker(time.Minute) - for range ticker.C { - now := time.Now() - // 清理过期的去重记录 - m.dedup.Range(func(key, value interface{}) bool { - if timestamp, ok := value.(time.Time); ok { - if now.Sub(timestamp) > constants.AlertDedupeWindow { - m.dedup.Delete(key) - } - } - return true - }) - } -} - -func (m *Monitor) recordAlert(title, message string) { - m.alerts <- Alert{ - Level: AlertLevelError, - Message: fmt.Sprintf("%s: %s", title, message), - Time: time.Now(), - } -} diff --git a/main.go b/main.go index bdc4530..0b7a799 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "log" "net/http" "os" @@ -13,7 +14,6 @@ import ( "proxy-go/internal/middleware" "strings" "syscall" - "text/template" ) func main() { @@ -23,15 +23,6 @@ func main() { log.Fatal("Error loading config:", err) } - // 加载模板 - tmpl := template.New("layout.html") - tmpl = template.Must(tmpl.ParseFiles( - "/app/web/templates/admin/layout.html", - "/app/web/templates/admin/login.html", - "/app/web/templates/admin/metrics.html", - "/app/web/templates/admin/config.html", - )) - // 更新常量配置 constants.UpdateFromConfig(cfg) @@ -61,58 +52,53 @@ func main() { return strings.HasPrefix(r.URL.Path, "/admin/") }, handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Printf("[Debug] 处理管理路由: %s", r.URL.Path) - - // 处理静态文件 - if strings.HasPrefix(r.URL.Path, "/admin/static/") { - log.Printf("[Debug] 处理静态文件: %s", r.URL.Path) - http.StripPrefix("/admin/static/", http.FileServer(http.Dir("/app/web/static"))).ServeHTTP(w, r) + // API请求处理 + if strings.HasPrefix(r.URL.Path, "/admin/api/") { + switch r.URL.Path { + case "/admin/api/auth": + if r.Method == http.MethodPost { + proxyHandler.AuthHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case "/admin/api/check-auth": + proxyHandler.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]bool{"authenticated": true}) + }))(w, r) + case "/admin/api/logout": + if r.Method == http.MethodPost { + proxyHandler.LogoutHandler(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case "/admin/api/metrics": + proxyHandler.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyHandler.MetricsHandler(w, r) + }))(w, r) + case "/admin/api/config/get": + proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r) + case "/admin/api/config/save": + proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r) + default: + http.NotFound(w, r) + } return } - switch r.URL.Path { - case "/admin/login": - log.Printf("[Debug] 提供登录页面") - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.ExecuteTemplate(w, "layout.html", map[string]interface{}{ - "Title": "管理员登录", - "Content": "login.html", - }); err != nil { - log.Printf("[Error] 渲染登录页面失败: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - case "/admin/metrics": - proxyHandler.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.ExecuteTemplate(w, "layout.html", map[string]interface{}{ - "Title": "监控面板", - "Content": "metrics.html", - }); err != nil { - log.Printf("[Error] 渲染监控页面失败: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - }))(w, r) - case "/admin/config": - proxyHandler.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.ExecuteTemplate(w, "layout.html", map[string]interface{}{ - "Title": "配置管理", - "Content": "config.html", - }); err != nil { - log.Printf("[Error] 渲染配置页面失败: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - }))(w, r) - case "/admin/config/get": - proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r) - case "/admin/config/save": - proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r) - case "/admin/auth": - proxyHandler.AuthHandler(w, r) - default: - log.Printf("[Debug] 未找到管理路由: %s", r.URL.Path) - http.NotFound(w, r) + // 静态文件处理 + path := r.URL.Path + if path == "/admin" || path == "/admin/" { + path = "/admin/index.html" } + + // 从web/out目录提供静态文件 + filePath := "web/out" + strings.TrimPrefix(path, "/admin") + if _, err := os.Stat(filePath); os.IsNotExist(err) { + // 如果文件不存在,返回index.html(用于客户端路由) + filePath = "web/out/index.html" + } + http.ServeFile(w, r, filePath) }), }, // Mirror代理处理器 @@ -145,15 +131,6 @@ func main() { // 创建主处理器 mainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Printf("[Debug] 收到请求: %s %s", r.Method, r.URL.Path) - - // 处理静态文件 - if strings.HasPrefix(r.URL.Path, "/admin/static/") { - log.Printf("[Debug] 处理静态文件: %s", r.URL.Path) - http.StripPrefix("/admin/static/", http.FileServer(http.Dir("/app/web/static"))).ServeHTTP(w, r) - return - } - // 遍历所有处理器 for _, h := range handlers { if h.matcher(r) { diff --git a/web/.eslintrc.json b/web/.eslintrc.json new file mode 100644 index 0000000..6c7e016 --- /dev/null +++ b/web/.eslintrc.json @@ -0,0 +1,8 @@ +{ + "extends": "next/core-web-vitals", + "rules": { + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-empty-object-type": "off", + "react-hooks/exhaustive-deps": "off" + } +} \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/web/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/web/app/dashboard/config/page.tsx b/web/app/dashboard/config/page.tsx new file mode 100644 index 0000000..e75394c --- /dev/null +++ b/web/app/dashboard/config/page.tsx @@ -0,0 +1,167 @@ +"use client" + +import { useEffect, useState } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useToast } from "@/components/ui/use-toast" +import { useRouter } from "next/navigation" + +export default function ConfigPage() { + const [config, setConfig] = useState("") + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const { toast } = useToast() + const router = useRouter() + + useEffect(() => { + fetchConfig() + }, []) + + const fetchConfig = async () => { + try { + const token = localStorage.getItem("token") + if (!token) { + router.push("/login") + return + } + + const response = await fetch("/api/config/get", { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }) + + if (response.status === 401) { + localStorage.removeItem("token") + router.push("/login") + return + } + + if (!response.ok) { + throw new Error("获取配置失败") + } + + const data = await response.json() + setConfig(JSON.stringify(data, null, 2)) + } catch (error) { + const message = error instanceof Error ? error.message : "获取配置失败" + toast({ + title: "错误", + description: message, + variant: "destructive", + }) + } finally { + setLoading(false) + } + } + + const handleSave = async () => { + setSaving(true) + try { + // 验证 JSON 格式 + const parsedConfig = JSON.parse(config) + + const token = localStorage.getItem("token") + if (!token) { + router.push("/login") + return + } + + const response = await fetch("/api/config/save", { + method: "POST", + headers: { + "Content-Type": "application/json", + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(parsedConfig), + }) + + if (response.status === 401) { + localStorage.removeItem("token") + router.push("/login") + return + } + + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.message || "保存配置失败") + } + + toast({ + title: "成功", + description: "配置已保存", + }) + } catch (error) { + toast({ + title: "错误", + description: error instanceof SyntaxError ? "JSON 格式错误" : error instanceof Error ? error.message : "保存配置失败", + variant: "destructive", + }) + } finally { + setSaving(false) + } + } + + const handleFormat = () => { + try { + const parsedConfig = JSON.parse(config) + setConfig(JSON.stringify(parsedConfig, null, 2)) + toast({ + title: "成功", + description: "配置已格式化", + }) + } catch { + toast({ + title: "错误", + description: "JSON 格式错误", + variant: "destructive", + }) + } + } + + if (loading) { + return ( +
+
+
加载中...
+
正在获取配置数据
+
+
+ ) + } + + return ( +
+ + + 代理服务配置 + + +
+
+ + + +
+
+