mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 00:21:56 +08:00
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
This commit is contained in:
parent
ecba8adbf1
commit
33d6a51416
35
.github/workflows/docker-build.yml
vendored
35
.github/workflows/docker-build.yml
vendored
@ -8,7 +8,34 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
jobs:
|
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
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@ -42,7 +69,7 @@ jobs:
|
|||||||
path: proxy-go-${{ matrix.arch }}
|
path: proxy-go-${{ matrix.arch }}
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: build
|
needs: [build-web, build-backend]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@ -63,14 +90,14 @@ jobs:
|
|||||||
username: woodchen
|
username: woodchen
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
- name: Create Docker build context
|
- name: Create Docker build context
|
||||||
run: |
|
run: |
|
||||||
mkdir -p docker-context
|
mkdir -p docker-context
|
||||||
cp Dockerfile docker-context/
|
cp Dockerfile docker-context/
|
||||||
cp proxy-go-amd64/proxy-go-amd64 docker-context/proxy-go.amd64
|
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 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
|
- name: Build and push Docker images
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -21,3 +21,7 @@ vendor/
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
data/config.json
|
||||||
|
data/config.json
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
|
# 构建后端
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY proxy-go.${TARGETARCH} /app/proxy-go
|
COPY proxy-go.${TARGETARCH} /app/proxy-go
|
||||||
COPY web /app/web
|
COPY web/out /app/web/out
|
||||||
|
|
||||||
RUN mkdir -p /app/data && \
|
RUN mkdir -p /app/data && \
|
||||||
chmod +x /app/proxy-go && \
|
chmod +x /app/proxy-go && \
|
||||||
|
@ -3,10 +3,26 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"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 {
|
type ConfigManager struct {
|
||||||
config atomic.Value
|
config atomic.Value
|
||||||
configPath string
|
configPath string
|
||||||
@ -26,18 +42,63 @@ func (cm *ConfigManager) watchConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load 加载配置
|
||||||
func Load(path string) (*Config, error) {
|
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)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var config Config
|
var newConfig Config
|
||||||
if err := json.Unmarshal(data, &config); err != nil {
|
if err := json.Unmarshal(data, &newConfig); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &config, nil
|
c.Update(&newConfig)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *ConfigManager) loadConfig() error {
|
func (cm *ConfigManager) loadConfig() error {
|
||||||
|
@ -3,7 +3,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -36,52 +35,10 @@ type FixedPathConfig struct {
|
|||||||
TargetURL string `json:"TargetURL"`
|
TargetURL string `json:"TargetURL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MetricsConfig 监控配置
|
||||||
type MetricsConfig struct {
|
type MetricsConfig struct {
|
||||||
Password string `json:"Password"`
|
Password string `json:"Password"` // 管理密码
|
||||||
TokenExpiry int `json:"TokenExpiry"`
|
TokenExpiry int `json:"TokenExpiry"` // Token过期时间(秒)
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加一个辅助方法来处理字符串到 PathConfig 的转换
|
// 添加一个辅助方法来处理字符串到 PathConfig 的转换
|
||||||
|
@ -15,99 +15,12 @@ var (
|
|||||||
MaxPathsStored = 1000 // 最大存储路径数
|
MaxPathsStored = 1000 // 最大存储路径数
|
||||||
MaxRecentLogs = 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
|
KB int64 = 1024
|
||||||
MB int64 = 1024 * KB
|
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 从配置文件更新常量
|
// UpdateFromConfig 从配置文件更新常量
|
||||||
func UpdateFromConfig(cfg *config.Config) {
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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 认证中间件
|
// AuthMiddleware 认证中间件
|
||||||
func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -86,19 +109,29 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
// AuthHandler 处理认证请求
|
// AuthHandler 处理认证请求
|
||||||
func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
log.Printf("[Auth] 方法不允许: %s", r.Method)
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
// 解析表单数据
|
||||||
Password string `json:"password"`
|
if err := r.ParseForm(); err != nil {
|
||||||
}
|
log.Printf("[Auth] 表单解析失败: %v", err)
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
return
|
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)
|
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -106,7 +139,10 @@ func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
token := h.auth.generateToken()
|
token := h.auth.generateToken()
|
||||||
h.auth.addToken(token, time.Duration(h.config.Metrics.TokenExpiry)*time.Second)
|
h.auth.addToken(token, time.Duration(h.config.Metrics.TokenExpiry)*time.Second)
|
||||||
|
|
||||||
|
log.Printf("[Auth] 登录成功,生成令牌")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
"token": token,
|
"token": token,
|
||||||
})
|
})
|
||||||
|
@ -24,11 +24,9 @@ func NewConfigHandler(cfg *config.Config) *ConfigHandler {
|
|||||||
// ServeHTTP 实现http.Handler接口
|
// ServeHTTP 实现http.Handler接口
|
||||||
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/metrics/config":
|
case "/admin/api/config/get":
|
||||||
h.handleConfigPage(w, r)
|
|
||||||
case "/metrics/config/get":
|
|
||||||
h.handleGetConfig(w, r)
|
h.handleGetConfig(w, r)
|
||||||
case "/metrics/config/save":
|
case "/admin/api/config/save":
|
||||||
h.handleSaveConfig(w, r)
|
h.handleSaveConfig(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@ -96,6 +94,7 @@ func (h *ConfigHandler) handleSaveConfig(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
// 更新运行时配置
|
// 更新运行时配置
|
||||||
*h.config = newConfig
|
*h.config = newConfig
|
||||||
|
config.TriggerCallbacks(h.config)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"message": "配置已更新并生效"}`))
|
w.Write([]byte(`{"message": "配置已更新并生效"}`))
|
||||||
|
@ -237,34 +237,33 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: dialer.DialContext,
|
DialContext: dialer.DialContext,
|
||||||
MaxIdleConns: 300, // 增加最大空闲连接数
|
MaxIdleConns: 300,
|
||||||
MaxIdleConnsPerHost: 50, // 增加每个主机的最大空闲连接数
|
MaxIdleConnsPerHost: 50,
|
||||||
IdleConnTimeout: idleConnTimeout,
|
IdleConnTimeout: idleConnTimeout,
|
||||||
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
MaxConnsPerHost: 100, // 增加每个主机的最大连接数
|
MaxConnsPerHost: 100,
|
||||||
DisableKeepAlives: false,
|
DisableKeepAlives: false,
|
||||||
DisableCompression: false,
|
DisableCompression: false,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
WriteBufferSize: 64 * 1024,
|
WriteBufferSize: 64 * 1024,
|
||||||
ReadBufferSize: 64 * 1024,
|
ReadBufferSize: 64 * 1024,
|
||||||
ResponseHeaderTimeout: backendServTimeout,
|
ResponseHeaderTimeout: backendServTimeout,
|
||||||
// HTTP/2 特定设置
|
MaxResponseHeaderBytes: 64 * 1024,
|
||||||
MaxResponseHeaderBytes: 64 * 1024, // 增加最大响应头大小
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置HTTP/2传输配置
|
// 设置HTTP/2传输配置
|
||||||
http2Transport, err := http2.ConfigureTransports(transport)
|
http2Transport, err := http2.ConfigureTransports(transport)
|
||||||
if err == nil && http2Transport != nil {
|
if err == nil && http2Transport != nil {
|
||||||
http2Transport.ReadIdleTimeout = 10 * time.Second // HTTP/2读取超时
|
http2Transport.ReadIdleTimeout = 10 * time.Second
|
||||||
http2Transport.PingTimeout = 5 * time.Second // HTTP/2 ping超时
|
http2Transport.PingTimeout = 5 * time.Second
|
||||||
http2Transport.AllowHTTP = false // 只允许HTTPS
|
http2Transport.AllowHTTP = false
|
||||||
http2Transport.MaxReadFrameSize = 32 * 1024 // 增加帧大小
|
http2Transport.MaxReadFrameSize = 32 * 1024
|
||||||
http2Transport.StrictMaxConcurrentStreams = true // 严格遵守最大并发流
|
http2Transport.StrictMaxConcurrentStreams = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ProxyHandler{
|
handler := &ProxyHandler{
|
||||||
pathMap: cfg.MAP,
|
pathMap: cfg.MAP,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Transport: transport,
|
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 允许自定义错误处理函数
|
// SetErrorHandler 允许自定义错误处理函数
|
||||||
|
@ -56,14 +56,6 @@ func InitCollector(config *config.Config) error {
|
|||||||
// 初始化监控器
|
// 初始化监控器
|
||||||
globalCollector.monitor = monitor.NewMonitor(globalCollector)
|
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{
|
globalCollector.statsPool = sync.Pool{
|
||||||
New: func() interface{} {
|
New: func() interface{} {
|
||||||
|
@ -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)
|
|
||||||
}
|
|
@ -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(),
|
|
||||||
}
|
|
||||||
}
|
|
111
main.go
111
main.go
@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@ -13,7 +14,6 @@ import (
|
|||||||
"proxy-go/internal/middleware"
|
"proxy-go/internal/middleware"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"text/template"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -23,15 +23,6 @@ func main() {
|
|||||||
log.Fatal("Error loading config:", err)
|
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)
|
constants.UpdateFromConfig(cfg)
|
||||||
|
|
||||||
@ -61,58 +52,53 @@ func main() {
|
|||||||
return strings.HasPrefix(r.URL.Path, "/admin/")
|
return strings.HasPrefix(r.URL.Path, "/admin/")
|
||||||
},
|
},
|
||||||
handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Printf("[Debug] 处理管理路由: %s", r.URL.Path)
|
// API请求处理
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/admin/api/") {
|
||||||
// 处理静态文件
|
switch r.URL.Path {
|
||||||
if strings.HasPrefix(r.URL.Path, "/admin/static/") {
|
case "/admin/api/auth":
|
||||||
log.Printf("[Debug] 处理静态文件: %s", r.URL.Path)
|
if r.Method == http.MethodPost {
|
||||||
http.StripPrefix("/admin/static/", http.FileServer(http.Dir("/app/web/static"))).ServeHTTP(w, r)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch r.URL.Path {
|
// 静态文件处理
|
||||||
case "/admin/login":
|
path := r.URL.Path
|
||||||
log.Printf("[Debug] 提供登录页面")
|
if path == "/admin" || path == "/admin/" {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
path = "/admin/index.html"
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从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代理处理器
|
// Mirror代理处理器
|
||||||
@ -145,15 +131,6 @@ func main() {
|
|||||||
|
|
||||||
// 创建主处理器
|
// 创建主处理器
|
||||||
mainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
for _, h := range handlers {
|
||||||
if h.matcher(r) {
|
if h.matcher(r) {
|
||||||
|
8
web/.eslintrc.json
Normal file
8
web/.eslintrc.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
41
web/.gitignore
vendored
Normal file
41
web/.gitignore
vendored
Normal file
@ -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
|
36
web/README.md
Normal file
36
web/README.md
Normal file
@ -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.
|
167
web/app/dashboard/config/page.tsx
Normal file
167
web/app/dashboard/config/page.tsx
Normal file
@ -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 (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-medium">加载中...</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">正在获取配置数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>代理服务配置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "保存中..." : "保存配置"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleFormat}>
|
||||||
|
格式化
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={fetchConfig}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
className="w-full h-[600px] p-4 font-mono text-sm rounded-md border bg-background"
|
||||||
|
value={config}
|
||||||
|
onChange={(e) => setConfig(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="加载配置失败"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
67
web/app/dashboard/layout.tsx
Normal file
67
web/app/dashboard/layout.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Nav } from "@/components/nav"
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
if (!token) {
|
||||||
|
router.push("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置全局请求拦截器
|
||||||
|
const originalFetch = window.fetch
|
||||||
|
window.fetch = async (...args) => {
|
||||||
|
const [resource, config = {}] = args
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
...config,
|
||||||
|
headers: {
|
||||||
|
...(config.headers || {}),
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await originalFetch(resource, newConfig)
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
router.push("/login")
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('请求失败:', error)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 token 有效性
|
||||||
|
fetch("/api/check-auth").catch(() => {
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
router.push("/login")
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.fetch = originalFetch
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<Nav />
|
||||||
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
311
web/app/dashboard/page.tsx
Normal file
311
web/app/dashboard/page.tsx
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
interface Metrics {
|
||||||
|
uptime: string
|
||||||
|
active_requests: number
|
||||||
|
total_requests: number
|
||||||
|
total_errors: number
|
||||||
|
num_goroutine: number
|
||||||
|
memory_usage: string
|
||||||
|
avg_response_time: string
|
||||||
|
requests_per_second: number
|
||||||
|
status_code_stats: Record<string, number>
|
||||||
|
top_paths: Array<{
|
||||||
|
path: string
|
||||||
|
request_count: number
|
||||||
|
error_count: number
|
||||||
|
avg_latency: string
|
||||||
|
bytes_transferred: number
|
||||||
|
}>
|
||||||
|
recent_requests: Array<{
|
||||||
|
Time: string
|
||||||
|
Path: string
|
||||||
|
Status: number
|
||||||
|
Latency: number
|
||||||
|
BytesSent: number
|
||||||
|
ClientIP: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { toast } = useToast()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const fetchMetrics = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
if (!token) {
|
||||||
|
router.push("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/metrics", {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
router.push("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("加载监控数据失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setMetrics(data)
|
||||||
|
setError(null)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "加载监控数据失败"
|
||||||
|
setError(message)
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: message,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 立即获取一次数据
|
||||||
|
fetchMetrics()
|
||||||
|
|
||||||
|
// 设置定时刷新
|
||||||
|
const interval = setInterval(fetchMetrics, 5000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-medium">加载中...</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">正在获取监控数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !metrics) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-medium text-red-600">
|
||||||
|
{error || "暂无数据"}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">
|
||||||
|
请检查后端服务是否正常运行
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchMetrics}
|
||||||
|
className="mt-4 px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>基础指标</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">运行时间</div>
|
||||||
|
<div className="text-lg font-semibold">{metrics.uptime}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">当前活跃请求</div>
|
||||||
|
<div className="text-lg font-semibold">{metrics.active_requests}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">总请求数</div>
|
||||||
|
<div className="text-lg font-semibold">{metrics.total_requests}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">错误数</div>
|
||||||
|
<div className="text-lg font-semibold">{metrics.total_errors}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>系统指标</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">Goroutine数量</div>
|
||||||
|
<div className="text-lg font-semibold">{metrics.num_goroutine}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">内存使用</div>
|
||||||
|
<div className="text-lg font-semibold">{metrics.memory_usage}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">平均响应时间</div>
|
||||||
|
<div className="text-lg font-semibold">{metrics.avg_response_time}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">每秒请求数</div>
|
||||||
|
<div className="text-lg font-semibold">
|
||||||
|
{metrics.requests_per_second.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>状态码统计</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
{Object.entries(metrics.status_code_stats || {})
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([status, count]) => (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className="p-4 rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-gray-500">
|
||||||
|
状态码 {status}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold">{count}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>热门路径 (Top 10)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left p-2">路径</th>
|
||||||
|
<th className="text-left p-2">请求数</th>
|
||||||
|
<th className="text-left p-2">错误数</th>
|
||||||
|
<th className="text-left p-2">平均延迟</th>
|
||||||
|
<th className="text-left p-2">传输大小</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(metrics.top_paths || []).map((path, index) => (
|
||||||
|
<tr key={index} className="border-b">
|
||||||
|
<td className="p-2">{path.path}</td>
|
||||||
|
<td className="p-2">{path.request_count}</td>
|
||||||
|
<td className="p-2">{path.error_count}</td>
|
||||||
|
<td className="p-2">{path.avg_latency}</td>
|
||||||
|
<td className="p-2">{formatBytes(path.bytes_transferred)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近请求</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left p-2">时间</th>
|
||||||
|
<th className="text-left p-2">路径</th>
|
||||||
|
<th className="text-left p-2">状态</th>
|
||||||
|
<th className="text-left p-2">延迟</th>
|
||||||
|
<th className="text-left p-2">大小</th>
|
||||||
|
<th className="text-left p-2">客户端IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(metrics.recent_requests || []).map((req, index) => (
|
||||||
|
<tr key={index} className="border-b">
|
||||||
|
<td className="p-2">{formatDate(req.Time)}</td>
|
||||||
|
<td className="p-2 max-w-xs truncate">{req.Path}</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs ${getStatusColor(
|
||||||
|
req.Status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{req.Status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-2">{formatLatency(req.Latency)}</td>
|
||||||
|
<td className="p-2">{formatBytes(req.BytesSent)}</td>
|
||||||
|
<td className="p-2">{req.ClientIP}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
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: string) {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLatency(nanoseconds: number) {
|
||||||
|
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 getStatusColor(status: number) {
|
||||||
|
if (status >= 500) return "bg-red-100 text-red-800"
|
||||||
|
if (status >= 400) return "bg-yellow-100 text-yellow-800"
|
||||||
|
if (status >= 300) return "bg-blue-100 text-blue-800"
|
||||||
|
return "bg-green-100 text-green-800"
|
||||||
|
}
|
BIN
web/app/favicon.ico
Normal file
BIN
web/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
72
web/app/globals.css
Normal file
72
web/app/globals.css
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 3.9%;
|
||||||
|
--primary: 0 0% 9%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 0 0% 96.1%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96.1%;
|
||||||
|
--muted-foreground: 0 0% 45.1%;
|
||||||
|
--accent: 0 0% 96.1%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 89.8%;
|
||||||
|
--input: 0 0% 89.8%;
|
||||||
|
--ring: 0 0% 3.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 0 0% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 0 0% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 0 0% 9%;
|
||||||
|
--secondary: 0 0% 14.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 0 0% 14.9%;
|
||||||
|
--muted-foreground: 0 0% 63.9%;
|
||||||
|
--accent: 0 0% 14.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 14.9%;
|
||||||
|
--input: 0 0% 14.9%;
|
||||||
|
--ring: 0 0% 83.1%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
26
web/app/layout.tsx
Normal file
26
web/app/layout.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "代理服务管理后台",
|
||||||
|
description: "代理服务管理后台",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body className={inter.className}>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
89
web/app/login/page.tsx
Normal file
89
web/app/login/page.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: `password=${encodeURIComponent(password)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("登录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
localStorage.setItem("token", data.token)
|
||||||
|
|
||||||
|
// 验证token
|
||||||
|
const verifyResponse = await fetch("/api/check-auth", {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${data.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verifyResponse.ok) {
|
||||||
|
// 登录成功,跳转到仪表盘
|
||||||
|
router.push("/dashboard")
|
||||||
|
} else {
|
||||||
|
throw new Error("Token验证失败")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: error instanceof Error ? error.message : "登录失败",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
|
<Card className="w-[400px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl text-center">管理员登录</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入管理密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "登录中..." : "登录"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
26
web/app/page.tsx
Normal file
26
web/app/page.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 检查是否已登录
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
if (token) {
|
||||||
|
router.push("/dashboard")
|
||||||
|
} else {
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
正在加载...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
21
web/components.json
Normal file
21
web/components.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
59
web/components/nav.tsx
Normal file
59
web/components/nav.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
|
||||||
|
export function Nav() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/logout", {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
toast({
|
||||||
|
title: "已退出登录",
|
||||||
|
})
|
||||||
|
router.push("/")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "退出失败",
|
||||||
|
description: "请稍后重试",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="border-b bg-white">
|
||||||
|
<div className="container mx-auto flex h-14 items-center px-4">
|
||||||
|
<div className="mr-4 font-bold">代理服务管理后台</div>
|
||||||
|
<div className="flex flex-1 items-center space-x-4 md:space-x-6">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className={pathname === "/dashboard" ? "text-primary" : "text-muted-foreground"}
|
||||||
|
>
|
||||||
|
仪表盘
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/config"
|
||||||
|
className={pathname === "/dashboard/config" ? "text-primary" : "text-muted-foreground"}
|
||||||
|
>
|
||||||
|
配置
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={handleLogout}>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
56
web/components/ui/button.tsx
Normal file
56
web/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
75
web/components/ui/card.tsx
Normal file
75
web/components/ui/card.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
23
web/components/ui/input.tsx
Normal file
23
web/components/ui/input.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
127
web/components/ui/toast.tsx
Normal file
127
web/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
35
web/components/ui/toaster.tsx
Normal file
35
web/components/ui/toaster.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(({ id, title, description, action, ...props }) => {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
189
web/components/ui/use-toast.ts
Normal file
189
web/components/ui/use-toast.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = {
|
||||||
|
ADD_TOAST: "ADD_TOAST"
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST"
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST"
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST"
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_VALUE
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open: boolean) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
16
web/eslint.config.mjs
Normal file
16
web/eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
6
web/lib/utils.ts
Normal file
6
web/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
23
web/next.config.js
Normal file
23
web/next.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: process.env.NODE_ENV === 'development' ? undefined : 'export',
|
||||||
|
basePath: process.env.NODE_ENV === 'development' ? '' : '/admin',
|
||||||
|
trailingSlash: true,
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
// 开发环境配置代理
|
||||||
|
async rewrites() {
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: 'http://localhost:3336/admin/api/:path*',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
6186
web/package-lock.json
generated
Normal file
6186
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
web/package.json
Normal file
34
web/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"next": "15.1.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.0.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.1.0",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
8
web/postcss.config.mjs
Normal file
8
web/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
1
web/public/file.svg
Normal file
1
web/public/file.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 391 B |
1
web/public/globe.svg
Normal file
1
web/public/globe.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
After Width: | Height: | Size: 1.0 KiB |
1
web/public/next.svg
Normal file
1
web/public/next.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
web/public/vercel.svg
Normal file
1
web/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 128 B |
1
web/public/window.svg
Normal file
1
web/public/window.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
After Width: | Height: | Size: 385 B |
@ -1,72 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
// 检查认证状态
|
|
||||||
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);
|
|
||||||
}
|
|
@ -1,66 +0,0 @@
|
|||||||
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();
|
|
@ -1,40 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
@ -1,105 +0,0 @@
|
|||||||
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);
|
|
62
web/tailwind.config.ts
Normal file
62
web/tailwind.config.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
|
},
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
chart: {
|
||||||
|
'1': 'hsl(var(--chart-1))',
|
||||||
|
'2': 'hsl(var(--chart-2))',
|
||||||
|
'3': 'hsl(var(--chart-3))',
|
||||||
|
'4': 'hsl(var(--chart-4))',
|
||||||
|
'5': 'hsl(var(--chart-5))'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
} satisfies Config;
|
@ -1,73 +0,0 @@
|
|||||||
{{define "config.html"}}
|
|
||||||
<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,42 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN" data-theme="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{.Title}} - 代理服务管理后台</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@3.9.4/dist/full.css" rel="stylesheet">
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-base-200">
|
|
||||||
<div class="navbar bg-base-100 shadow-lg mb-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<a href="/admin/metrics" class="btn btn-ghost normal-case text-xl">代理服务管理</a>
|
|
||||||
</div>
|
|
||||||
<div class="flex-none">
|
|
||||||
<ul class="menu menu-horizontal px-1">
|
|
||||||
<li><a href="/admin/metrics">监控面板</a></li>
|
|
||||||
<li><a href="/admin/config">配置管理</a></li>
|
|
||||||
<li><a onclick="logout()" class="cursor-pointer">退出登录</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container mx-auto px-4">
|
|
||||||
{{if eq .Content "login.html"}}
|
|
||||||
{{template "login.html" .}}
|
|
||||||
{{else if eq .Content "metrics.html"}}
|
|
||||||
{{template "metrics.html" .}}
|
|
||||||
{{else if eq .Content "config.html"}}
|
|
||||||
{{template "config.html" .}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/admin/static/js/auth.js"></script>
|
|
||||||
<script>
|
|
||||||
// 检查是否已登录(除了登录页面)
|
|
||||||
if (!window.location.pathname.includes('/login')) {
|
|
||||||
checkAuth();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,19 +0,0 @@
|
|||||||
{{define "login.html"}}
|
|
||||||
<div class="flex items-center justify-center min-h-[80vh]">
|
|
||||||
<div class="card w-96 bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title justify-center text-2xl font-bold mb-6">管理员登录</h2>
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">密码</span>
|
|
||||||
</label>
|
|
||||||
<input type="password" id="password" placeholder="请输入管理密码" class="input input-bordered w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="card-actions justify-end mt-6">
|
|
||||||
<button onclick="login()" class="btn btn-primary w-full">登录</button>
|
|
||||||
</div>
|
|
||||||
<div id="message" class="alert mt-4" style="display: none;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
@ -1,216 +0,0 @@
|
|||||||
{{define "metrics.html"}}
|
|
||||||
<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}}
|
|
27
web/tsconfig.json
Normal file
27
web/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user