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:
wood chen 2025-02-15 11:44:09 +08:00
parent ecba8adbf1
commit 33d6a51416
54 changed files with 8019 additions and 1318 deletions

View File

@ -8,7 +8,34 @@ on:
branches: [ main ]
jobs:
build:
build-web:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
working-directory: web
run: npm ci
- name: Build web
working-directory: web
run: npm run build
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
name: web-out
path: web/out
build-backend:
runs-on: ubuntu-latest
strategy:
matrix:
@ -42,7 +69,7 @@ jobs:
path: proxy-go-${{ matrix.arch }}
docker:
needs: build
needs: [build-web, build-backend]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@ -63,14 +90,14 @@ jobs:
username: woodchen
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create Docker build context
run: |
mkdir -p docker-context
cp Dockerfile docker-context/
cp proxy-go-amd64/proxy-go-amd64 docker-context/proxy-go.amd64
cp proxy-go-arm64/proxy-go-arm64 docker-context/proxy-go.arm64
cp -r web docker-context/
mkdir -p docker-context/web/out
cp -r web-out/* docker-context/web/out/
- name: Build and push Docker images
uses: docker/build-push-action@v6

4
.gitignore vendored
View File

@ -21,3 +21,7 @@ vendor/
.vscode/
*.swp
*.swo
web/node_modules/
web/dist/
data/config.json
data/config.json

View File

@ -1,10 +1,11 @@
# 构建后端
FROM alpine:latest
ARG TARGETARCH
WORKDIR /app
COPY proxy-go.${TARGETARCH} /app/proxy-go
COPY web /app/web
COPY web/out /app/web/out
RUN mkdir -p /app/data && \
chmod +x /app/proxy-go && \

View File

@ -3,10 +3,26 @@ package config
import (
"encoding/json"
"os"
"sync"
"sync/atomic"
"time"
)
// Config 配置结构体
type configImpl struct {
sync.RWMutex
Config
// 配置更新回调函数
onConfigUpdate []func(*Config)
}
var (
instance *configImpl
once sync.Once
configCallbacks []func(*Config)
callbackMutex sync.RWMutex
)
type ConfigManager struct {
config atomic.Value
configPath string
@ -26,18 +42,63 @@ func (cm *ConfigManager) watchConfig() {
}
}
// Load 加载配置
func Load(path string) (*Config, error) {
var err error
once.Do(func() {
instance = &configImpl{}
err = instance.reload(path)
})
return &instance.Config, err
}
// RegisterUpdateCallback 注册配置更新回调函数
func RegisterUpdateCallback(callback func(*Config)) {
callbackMutex.Lock()
defer callbackMutex.Unlock()
configCallbacks = append(configCallbacks, callback)
}
// TriggerCallbacks 触发所有回调
func TriggerCallbacks(cfg *Config) {
callbackMutex.RLock()
defer callbackMutex.RUnlock()
for _, callback := range configCallbacks {
callback(cfg)
}
}
// Update 更新配置并触发回调
func (c *configImpl) Update(newConfig *Config) {
c.Lock()
defer c.Unlock()
// 更新配置
c.MAP = newConfig.MAP
c.Compression = newConfig.Compression
c.FixedPaths = newConfig.FixedPaths
c.Metrics = newConfig.Metrics
// 触发回调
for _, callback := range c.onConfigUpdate {
callback(newConfig)
}
}
// reload 重新加载配置文件
func (c *configImpl) reload(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
return err
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
var newConfig Config
if err := json.Unmarshal(data, &newConfig); err != nil {
return err
}
return &config, nil
c.Update(&newConfig)
return nil
}
func (cm *ConfigManager) loadConfig() error {

View File

@ -3,7 +3,6 @@ package config
import (
"encoding/json"
"strings"
"time"
)
type Config struct {
@ -36,52 +35,10 @@ type FixedPathConfig struct {
TargetURL string `json:"TargetURL"`
}
// MetricsConfig 监控配置
type MetricsConfig struct {
Password string `json:"Password"`
TokenExpiry int `json:"TokenExpiry"`
FeishuWebhook string `json:"FeishuWebhook"`
// 监控告警配置
Alert struct {
WindowSize int `json:"WindowSize"` // 监控窗口数量
WindowInterval time.Duration `json:"WindowInterval"` // 每个窗口时间长度
DedupeWindow time.Duration `json:"DedupeWindow"` // 告警去重时间窗口
MinRequests int64 `json:"MinRequests"` // 触发告警的最小请求数
ErrorRate float64 `json:"ErrorRate"` // 错误率告警阈值
AlertInterval time.Duration `json:"AlertInterval"` // 告警间隔时间
} `json:"Alert"`
// 延迟告警配置
Latency struct {
SmallFileSize int64 `json:"SmallFileSize"` // 小文件阈值
MediumFileSize int64 `json:"MediumFileSize"` // 中等文件阈值
LargeFileSize int64 `json:"LargeFileSize"` // 大文件阈值
SmallLatency time.Duration `json:"SmallLatency"` // 小文件最大延迟
MediumLatency time.Duration `json:"MediumLatency"` // 中等文件最大延迟
LargeLatency time.Duration `json:"LargeLatency"` // 大文件最大延迟
HugeLatency time.Duration `json:"HugeLatency"` // 超大文件最大延迟
} `json:"Latency"`
// 性能监控配置
Performance struct {
MaxRequestsPerMinute int64 `json:"MaxRequestsPerMinute"`
MaxBytesPerMinute int64 `json:"MaxBytesPerMinute"`
MaxSaveInterval time.Duration `json:"MaxSaveInterval"`
} `json:"Performance"`
// 加载配置
Load struct {
RetryCount int `json:"retry_count"`
RetryInterval time.Duration `json:"retry_interval"`
Timeout time.Duration `json:"timeout"`
} `json:"load"`
// 保存配置
Save struct {
MinInterval time.Duration `json:"min_interval"`
MaxInterval time.Duration `json:"max_interval"`
DefaultInterval time.Duration `json:"default_interval"`
} `json:"save"`
// 验证配置
Validation struct {
MaxErrorRate float64 `json:"max_error_rate"`
MaxDataDeviation float64 `json:"max_data_deviation"`
} `json:"validation"`
Password string `json:"Password"` // 管理密码
TokenExpiry int `json:"TokenExpiry"` // Token过期时间(秒)
}
// 添加一个辅助方法来处理字符串到 PathConfig 的转换

View File

@ -15,99 +15,12 @@ var (
MaxPathsStored = 1000 // 最大存储路径数
MaxRecentLogs = 1000 // 最大最近日志数
// 监控告警相关
AlertWindowSize = 12 // 监控窗口数量
AlertWindowInterval = 5 * time.Minute // 每个窗口时间长度
AlertDedupeWindow = 15 * time.Minute // 告警去重时间窗口
AlertNotifyInterval = 24 * time.Hour // 告警通知间隔
MinRequestsForAlert int64 = 10 // 触发告警的最小请求数
ErrorRateThreshold = 0.8 // 错误率告警阈值
// 延迟告警阈值
SmallFileSize int64 = 1 * MB // 小文件阈值
MediumFileSize int64 = 10 * MB // 中等文件阈值
LargeFileSize int64 = 100 * MB // 大文件阈值
SmallFileLatency = 5 * time.Second // 小文件最大延迟
MediumFileLatency = 10 * time.Second // 中等文件最大延迟
LargeFileLatency = 50 * time.Second // 大文件最大延迟
HugeFileLatency = 300 * time.Second // 超大文件最大延迟 (5分钟)
// 单位常量
KB int64 = 1024
MB int64 = 1024 * KB
// 数据验证相关
MaxErrorRate = 0.8 // 最大错误率
MaxDataDeviation = 0.01 // 最大数据偏差(1%)
// 性能监控阈值
MaxRequestsPerMinute int64 = 1000 // 每分钟最大请求数
MaxBytesPerMinute int64 = 100 * 1024 * 1024 // 每分钟最大流量 (100MB)
MaxSaveInterval = 15 * time.Minute // 最大保存间隔
)
// UpdateFromConfig 从配置文件更新常量
func UpdateFromConfig(cfg *config.Config) {
// 告警配置
if cfg.Metrics.Alert.WindowSize > 0 {
AlertWindowSize = cfg.Metrics.Alert.WindowSize
}
if cfg.Metrics.Alert.WindowInterval > 0 {
AlertWindowInterval = cfg.Metrics.Alert.WindowInterval
}
if cfg.Metrics.Alert.DedupeWindow > 0 {
AlertDedupeWindow = cfg.Metrics.Alert.DedupeWindow
}
if cfg.Metrics.Alert.MinRequests > 0 {
MinRequestsForAlert = cfg.Metrics.Alert.MinRequests
}
if cfg.Metrics.Alert.ErrorRate > 0 {
ErrorRateThreshold = cfg.Metrics.Alert.ErrorRate
}
if cfg.Metrics.Alert.AlertInterval > 0 {
AlertNotifyInterval = cfg.Metrics.Alert.AlertInterval
}
// 延迟告警配置
if cfg.Metrics.Latency.SmallFileSize > 0 {
SmallFileSize = cfg.Metrics.Latency.SmallFileSize
}
if cfg.Metrics.Latency.MediumFileSize > 0 {
MediumFileSize = cfg.Metrics.Latency.MediumFileSize
}
if cfg.Metrics.Latency.LargeFileSize > 0 {
LargeFileSize = cfg.Metrics.Latency.LargeFileSize
}
if cfg.Metrics.Latency.SmallLatency > 0 {
SmallFileLatency = cfg.Metrics.Latency.SmallLatency
}
if cfg.Metrics.Latency.MediumLatency > 0 {
MediumFileLatency = cfg.Metrics.Latency.MediumLatency
}
if cfg.Metrics.Latency.LargeLatency > 0 {
LargeFileLatency = cfg.Metrics.Latency.LargeLatency
}
if cfg.Metrics.Latency.HugeLatency > 0 {
HugeFileLatency = cfg.Metrics.Latency.HugeLatency
}
// 数据验证相关
if cfg.Metrics.Validation.MaxErrorRate > 0 {
MaxErrorRate = cfg.Metrics.Validation.MaxErrorRate
}
if cfg.Metrics.Validation.MaxDataDeviation > 0 {
MaxDataDeviation = cfg.Metrics.Validation.MaxDataDeviation
}
// 性能监控阈值
if cfg.Metrics.Performance.MaxRequestsPerMinute > 0 {
MaxRequestsPerMinute = cfg.Metrics.Performance.MaxRequestsPerMinute
}
if cfg.Metrics.Performance.MaxBytesPerMinute > 0 {
MaxBytesPerMinute = cfg.Metrics.Performance.MaxBytesPerMinute
}
if cfg.Metrics.Performance.MaxSaveInterval > 0 {
MaxSaveInterval = cfg.Metrics.Performance.MaxSaveInterval
}
// 空实现,不再需要更新监控相关配置
}

View File

@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"log"
"net/http"
"strings"
"sync"
@ -64,6 +65,28 @@ func (am *authManager) cleanExpiredTokens() {
}
}
// CheckAuth 检查认证令牌是否有效
func (h *ProxyHandler) CheckAuth(token string) bool {
return h.auth.validateToken(token)
}
// LogoutHandler 处理退出登录请求
func (h *ProxyHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(auth, "Bearer ")
h.auth.tokens.Delete(token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "已退出登录",
})
}
// AuthMiddleware 认证中间件
func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@ -86,19 +109,29 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
// AuthHandler 处理认证请求
func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
log.Printf("[Auth] 方法不允许: %s", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// 解析表单数据
if err := r.ParseForm(); err != nil {
log.Printf("[Auth] 表单解析失败: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.Password != h.config.Metrics.Password {
password := r.FormValue("password")
log.Printf("[Auth] 收到登录请求,密码长度: %d", len(password))
if password == "" {
log.Printf("[Auth] 密码为空")
http.Error(w, "Password is required", http.StatusBadRequest)
return
}
if password != h.config.Metrics.Password {
log.Printf("[Auth] 密码错误")
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
@ -106,7 +139,10 @@ func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
token := h.auth.generateToken()
h.auth.addToken(token, time.Duration(h.config.Metrics.TokenExpiry)*time.Second)
log.Printf("[Auth] 登录成功,生成令牌")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"token": token,
})

View File

@ -24,11 +24,9 @@ func NewConfigHandler(cfg *config.Config) *ConfigHandler {
// ServeHTTP 实现http.Handler接口
func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/metrics/config":
h.handleConfigPage(w, r)
case "/metrics/config/get":
case "/admin/api/config/get":
h.handleGetConfig(w, r)
case "/metrics/config/save":
case "/admin/api/config/save":
h.handleSaveConfig(w, r)
default:
http.NotFound(w, r)
@ -96,6 +94,7 @@ func (h *ConfigHandler) handleSaveConfig(w http.ResponseWriter, r *http.Request)
// 更新运行时配置
*h.config = newConfig
config.TriggerCallbacks(h.config)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message": "配置已更新并生效"}`))

View File

@ -238,33 +238,32 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
transport := &http.Transport{
DialContext: dialer.DialContext,
MaxIdleConns: 300, // 增加最大空闲连接数
MaxIdleConnsPerHost: 50, // 增加每个主机的最大空闲连接数
MaxIdleConns: 300,
MaxIdleConnsPerHost: 50,
IdleConnTimeout: idleConnTimeout,
TLSHandshakeTimeout: tlsHandshakeTimeout,
ExpectContinueTimeout: 1 * time.Second,
MaxConnsPerHost: 100, // 增加每个主机的最大连接数
MaxConnsPerHost: 100,
DisableKeepAlives: false,
DisableCompression: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
ResponseHeaderTimeout: backendServTimeout,
// HTTP/2 特定设置
MaxResponseHeaderBytes: 64 * 1024, // 增加最大响应头大小
MaxResponseHeaderBytes: 64 * 1024,
}
// 设置HTTP/2传输配置
http2Transport, err := http2.ConfigureTransports(transport)
if err == nil && http2Transport != nil {
http2Transport.ReadIdleTimeout = 10 * time.Second // HTTP/2读取超时
http2Transport.PingTimeout = 5 * time.Second // HTTP/2 ping超时
http2Transport.AllowHTTP = false // 只允许HTTPS
http2Transport.MaxReadFrameSize = 32 * 1024 // 增加帧大小
http2Transport.StrictMaxConcurrentStreams = true // 严格遵守最大并发流
http2Transport.ReadIdleTimeout = 10 * time.Second
http2Transport.PingTimeout = 5 * time.Second
http2Transport.AllowHTTP = false
http2Transport.MaxReadFrameSize = 32 * 1024
http2Transport.StrictMaxConcurrentStreams = true
}
return &ProxyHandler{
handler := &ProxyHandler{
pathMap: cfg.MAP,
client: &http.Client{
Transport: transport,
@ -292,6 +291,15 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
}
},
}
// 注册配置更新回调
config.RegisterUpdateCallback(func(newCfg *config.Config) {
handler.pathMap = newCfg.MAP
handler.config = newCfg
log.Printf("[Config] 配置已更新并生效")
})
return handler
}
// SetErrorHandler 允许自定义错误处理函数

View File

@ -56,14 +56,6 @@ func InitCollector(config *config.Config) error {
// 初始化监控器
globalCollector.monitor = monitor.NewMonitor(globalCollector)
// 如果配置了飞书webhook则启用飞书告警
if config.Metrics.FeishuWebhook != "" {
globalCollector.monitor.AddHandler(
monitor.NewFeishuHandler(config.Metrics.FeishuWebhook),
)
log.Printf("Feishu alert enabled")
}
// 初始化对象池
globalCollector.statsPool = sync.Pool{
New: func() interface{} {

View File

@ -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)
}

View File

@ -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(),
}
}

109
main.go
View File

@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os"
@ -13,7 +14,6 @@ import (
"proxy-go/internal/middleware"
"strings"
"syscall"
"text/template"
)
func main() {
@ -23,15 +23,6 @@ func main() {
log.Fatal("Error loading config:", err)
}
// 加载模板
tmpl := template.New("layout.html")
tmpl = template.Must(tmpl.ParseFiles(
"/app/web/templates/admin/layout.html",
"/app/web/templates/admin/login.html",
"/app/web/templates/admin/metrics.html",
"/app/web/templates/admin/config.html",
))
// 更新常量配置
constants.UpdateFromConfig(cfg)
@ -61,58 +52,53 @@ func main() {
return strings.HasPrefix(r.URL.Path, "/admin/")
},
handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("[Debug] 处理管理路由: %s", r.URL.Path)
// 处理静态文件
if strings.HasPrefix(r.URL.Path, "/admin/static/") {
log.Printf("[Debug] 处理静态文件: %s", r.URL.Path)
http.StripPrefix("/admin/static/", http.FileServer(http.Dir("/app/web/static"))).ServeHTTP(w, r)
// API请求处理
if strings.HasPrefix(r.URL.Path, "/admin/api/") {
switch r.URL.Path {
case "/admin/api/auth":
if r.Method == http.MethodPost {
proxyHandler.AuthHandler(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case "/admin/api/check-auth":
proxyHandler.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"authenticated": true})
}))(w, r)
case "/admin/api/logout":
if r.Method == http.MethodPost {
proxyHandler.LogoutHandler(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case "/admin/api/metrics":
proxyHandler.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyHandler.MetricsHandler(w, r)
}))(w, r)
case "/admin/api/config/get":
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
case "/admin/api/config/save":
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
default:
http.NotFound(w, r)
}
return
}
switch r.URL.Path {
case "/admin/login":
log.Printf("[Debug] 提供登录页面")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout.html", map[string]interface{}{
"Title": "管理员登录",
"Content": "login.html",
}); err != nil {
log.Printf("[Error] 渲染登录页面失败: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
// 静态文件处理
path := r.URL.Path
if path == "/admin" || path == "/admin/" {
path = "/admin/index.html"
}
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代理处理器
@ -145,15 +131,6 @@ func main() {
// 创建主处理器
mainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("[Debug] 收到请求: %s %s", r.Method, r.URL.Path)
// 处理静态文件
if strings.HasPrefix(r.URL.Path, "/admin/static/") {
log.Printf("[Debug] 处理静态文件: %s", r.URL.Path)
http.StripPrefix("/admin/static/", http.FileServer(http.Dir("/app/web/static"))).ServeHTTP(w, r)
return
}
// 遍历所有处理器
for _, h := range handlers {
if h.matcher(r) {

8
web/.eslintrc.json Normal file
View 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
View 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
View 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.

View 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>
)
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

72
web/app/globals.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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 }

View 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 }

View 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
View 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,
}

View 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>
)
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

34
web/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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();

View File

@ -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();
}
});

View File

@ -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
View 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;

View File

@ -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}}

View File

@ -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>

View File

@ -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}}

View File

@ -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
View 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"]
}