mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 16:41:54 +08:00
feat(cache): Add dynamic cache configuration management
- Implement GetConfig and UpdateConfig methods in CacheManager - Add cache configuration endpoints in CacheAdminHandler - Create frontend UI for dynamically updating cache settings - Support configurable max age, cleanup interval, and cache size - Add input validation for cache configuration updates
This commit is contained in:
parent
d00ab0a6e1
commit
a4c4688412
62
internal/cache/manager.go
vendored
62
internal/cache/manager.go
vendored
@ -76,6 +76,8 @@ type CacheManager struct {
|
|||||||
hitCount atomic.Int64 // 命中计数
|
hitCount atomic.Int64 // 命中计数
|
||||||
missCount atomic.Int64 // 未命中计数
|
missCount atomic.Int64 // 未命中计数
|
||||||
bytesSaved atomic.Int64 // 节省的带宽
|
bytesSaved atomic.Int64 // 节省的带宽
|
||||||
|
cleanupTimer *time.Ticker // 添加清理定时器
|
||||||
|
stopCleanup chan struct{} // 添加停止信号通道
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCacheManager 创建新的缓存管理器
|
// NewCacheManager 创建新的缓存管理器
|
||||||
@ -89,12 +91,13 @@ func NewCacheManager(cacheDir string) (*CacheManager, error) {
|
|||||||
maxAge: 30 * time.Minute,
|
maxAge: 30 * time.Minute,
|
||||||
cleanupTick: 5 * time.Minute,
|
cleanupTick: 5 * time.Minute,
|
||||||
maxCacheSize: 10 * 1024 * 1024 * 1024, // 10GB
|
maxCacheSize: 10 * 1024 * 1024 * 1024, // 10GB
|
||||||
|
stopCleanup: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
cm.enabled.Store(true) // 默认启用缓存
|
cm.enabled.Store(true) // 默认启用缓存
|
||||||
|
|
||||||
// 启动清理协程
|
// 启动清理协程
|
||||||
go cm.cleanup()
|
cm.startCleanup()
|
||||||
|
|
||||||
return cm, nil
|
return cm, nil
|
||||||
}
|
}
|
||||||
@ -214,8 +217,6 @@ func (cm *CacheManager) Put(key CacheKey, resp *http.Response, body []byte) (*Ca
|
|||||||
|
|
||||||
// cleanup 定期清理过期的缓存项
|
// cleanup 定期清理过期的缓存项
|
||||||
func (cm *CacheManager) cleanup() {
|
func (cm *CacheManager) cleanup() {
|
||||||
ticker := time.NewTicker(cm.cleanupTick)
|
|
||||||
for range ticker.C {
|
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
var keysToDelete []CacheKey
|
var keysToDelete []CacheKey
|
||||||
|
|
||||||
@ -269,7 +270,6 @@ func (cm *CacheManager) cleanup() {
|
|||||||
log.Printf("[Cache] Removed expired item: %s", key.URL)
|
log.Printf("[Cache] Removed expired item: %s", key.URL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatBytes 格式化字节大小
|
// formatBytes 格式化字节大小
|
||||||
@ -408,3 +408,57 @@ func (cm *CacheManager) Commit(key CacheKey, tempPath string, resp *http.Respons
|
|||||||
log.Printf("[Cache] Cached %s (%s)", key.URL, formatBytes(size))
|
log.Printf("[Cache] Cached %s (%s)", key.URL, formatBytes(size))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig 获取缓存配置
|
||||||
|
func (cm *CacheManager) GetConfig() CacheConfig {
|
||||||
|
return CacheConfig{
|
||||||
|
MaxAge: int64(cm.maxAge.Minutes()),
|
||||||
|
CleanupTick: int64(cm.cleanupTick.Minutes()),
|
||||||
|
MaxCacheSize: cm.maxCacheSize / (1024 * 1024 * 1024), // 转换为GB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConfig 更新缓存配置
|
||||||
|
func (cm *CacheManager) UpdateConfig(maxAge, cleanupTick, maxCacheSize int64) error {
|
||||||
|
if maxAge <= 0 || cleanupTick <= 0 || maxCacheSize <= 0 {
|
||||||
|
return fmt.Errorf("invalid config values: all values must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.maxAge = time.Duration(maxAge) * time.Minute
|
||||||
|
cm.maxCacheSize = maxCacheSize * 1024 * 1024 * 1024 // 转换为字节
|
||||||
|
|
||||||
|
// 如果清理间隔发生变化,重启清理协程
|
||||||
|
newCleanupTick := time.Duration(cleanupTick) * time.Minute
|
||||||
|
if cm.cleanupTick != newCleanupTick {
|
||||||
|
cm.cleanupTick = newCleanupTick
|
||||||
|
// 停止当前的清理协程
|
||||||
|
cm.stopCleanup <- struct{}{}
|
||||||
|
// 启动新的清理协程
|
||||||
|
cm.startCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheConfig 缓存配置结构
|
||||||
|
type CacheConfig struct {
|
||||||
|
MaxAge int64 `json:"max_age"` // 最大缓存时间(分钟)
|
||||||
|
CleanupTick int64 `json:"cleanup_tick"` // 清理间隔(分钟)
|
||||||
|
MaxCacheSize int64 `json:"max_cache_size"` // 最大缓存大小(GB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCleanup 启动清理协程
|
||||||
|
func (cm *CacheManager) startCleanup() {
|
||||||
|
cm.cleanupTimer = time.NewTicker(cm.cleanupTick)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-cm.cleanupTimer.C:
|
||||||
|
cm.cleanup()
|
||||||
|
case <-cm.stopCleanup:
|
||||||
|
cm.cleanupTimer.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
@ -20,6 +20,13 @@ func NewCacheAdminHandler(proxyCache, mirrorCache, fixedPathCache *cache.CacheMa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheConfig 缓存配置结构
|
||||||
|
type CacheConfig struct {
|
||||||
|
MaxAge int64 `json:"max_age"` // 最大缓存时间(分钟)
|
||||||
|
CleanupTick int64 `json:"cleanup_tick"` // 清理间隔(分钟)
|
||||||
|
MaxCacheSize int64 `json:"max_cache_size"` // 最大缓存大小(GB)
|
||||||
|
}
|
||||||
|
|
||||||
// GetCacheStats 获取缓存统计信息
|
// GetCacheStats 获取缓存统计信息
|
||||||
func (h *CacheAdminHandler) GetCacheStats(w http.ResponseWriter, r *http.Request) {
|
func (h *CacheAdminHandler) GetCacheStats(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
@ -37,6 +44,61 @@ func (h *CacheAdminHandler) GetCacheStats(w http.ResponseWriter, r *http.Request
|
|||||||
json.NewEncoder(w).Encode(stats)
|
json.NewEncoder(w).Encode(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCacheConfig 获取缓存配置
|
||||||
|
func (h *CacheAdminHandler) GetCacheConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configs := map[string]cache.CacheConfig{
|
||||||
|
"proxy": h.proxyCache.GetConfig(),
|
||||||
|
"mirror": h.mirrorCache.GetConfig(),
|
||||||
|
"fixedPath": h.fixedPathCache.GetConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(configs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCacheConfig 更新缓存配置
|
||||||
|
func (h *CacheAdminHandler) UpdateCacheConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type"` // "proxy", "mirror" 或 "fixedPath"
|
||||||
|
Config CacheConfig `json:"config"` // 新的配置
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetCache *cache.CacheManager
|
||||||
|
switch req.Type {
|
||||||
|
case "proxy":
|
||||||
|
targetCache = h.proxyCache
|
||||||
|
case "mirror":
|
||||||
|
targetCache = h.mirrorCache
|
||||||
|
case "fixedPath":
|
||||||
|
targetCache = h.fixedPathCache
|
||||||
|
default:
|
||||||
|
http.Error(w, "Invalid cache type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := targetCache.UpdateConfig(req.Config.MaxAge, req.Config.CleanupTick, req.Config.MaxCacheSize); err != nil {
|
||||||
|
http.Error(w, "Failed to update config: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
// SetCacheEnabled 设置缓存开关状态
|
// SetCacheEnabled 设置缓存开关状态
|
||||||
func (h *CacheAdminHandler) SetCacheEnabled(w http.ResponseWriter, r *http.Request) {
|
func (h *CacheAdminHandler) SetCacheEnabled(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
@ -194,6 +194,21 @@ func (c *Collector) RecordRequest(path string, status int, latency time.Duration
|
|||||||
c.recentRequestsMutex.Unlock()
|
c.recentRequestsMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatUptime 格式化运行时间
|
||||||
|
func formatUptime(d time.Duration) string {
|
||||||
|
days := int(d.Hours()) / 24
|
||||||
|
hours := int(d.Hours()) % 24
|
||||||
|
minutes := int(d.Minutes()) % 60
|
||||||
|
|
||||||
|
if days > 0 {
|
||||||
|
return fmt.Sprintf("%d天%d小时%d分钟", days, hours, minutes)
|
||||||
|
}
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%d小时%d分钟", hours, minutes)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d分钟", minutes)
|
||||||
|
}
|
||||||
|
|
||||||
// GetStats 获取统计数据
|
// GetStats 获取统计数据
|
||||||
func (c *Collector) GetStats() map[string]interface{} {
|
func (c *Collector) GetStats() map[string]interface{} {
|
||||||
var mem runtime.MemStats
|
var mem runtime.MemStats
|
||||||
@ -274,7 +289,7 @@ func (c *Collector) GetStats() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"uptime": time.Since(c.startTime).String(),
|
"uptime": formatUptime(time.Since(c.startTime)),
|
||||||
"active_requests": atomic.LoadInt64(&c.activeRequests),
|
"active_requests": atomic.LoadInt64(&c.activeRequests),
|
||||||
"total_requests": atomic.LoadInt64(&c.totalRequests),
|
"total_requests": atomic.LoadInt64(&c.totalRequests),
|
||||||
"total_errors": atomic.LoadInt64(&c.totalErrors),
|
"total_errors": atomic.LoadInt64(&c.totalErrors),
|
||||||
|
116
web/app/dashboard/cache/page.tsx
vendored
116
web/app/dashboard/cache/page.tsx
vendored
@ -5,6 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
total_items: number
|
total_items: number
|
||||||
@ -16,12 +18,24 @@ interface CacheStats {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CacheConfig {
|
||||||
|
max_age: number
|
||||||
|
cleanup_tick: number
|
||||||
|
max_cache_size: number
|
||||||
|
}
|
||||||
|
|
||||||
interface CacheData {
|
interface CacheData {
|
||||||
proxy: CacheStats
|
proxy: CacheStats
|
||||||
mirror: CacheStats
|
mirror: CacheStats
|
||||||
fixedPath: CacheStats
|
fixedPath: CacheStats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CacheConfigs {
|
||||||
|
proxy: CacheConfig
|
||||||
|
mirror: CacheConfig
|
||||||
|
fixedPath: CacheConfig
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number) {
|
function formatBytes(bytes: number) {
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
let size = bytes
|
let size = bytes
|
||||||
@ -37,6 +51,7 @@ function formatBytes(bytes: number) {
|
|||||||
|
|
||||||
export default function CachePage() {
|
export default function CachePage() {
|
||||||
const [stats, setStats] = useState<CacheData | null>(null)
|
const [stats, setStats] = useState<CacheData | null>(null)
|
||||||
|
const [configs, setConfigs] = useState<CacheConfigs | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
@ -57,14 +72,30 @@ export default function CachePage() {
|
|||||||
}
|
}
|
||||||
}, [toast])
|
}, [toast])
|
||||||
|
|
||||||
|
const fetchConfigs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/api/cache/config")
|
||||||
|
if (!response.ok) throw new Error("获取缓存配置失败")
|
||||||
|
const data = await response.json()
|
||||||
|
setConfigs(data)
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: error instanceof Error ? error.message : "获取缓存配置失败",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 立即获取一次数据
|
// 立即获取一次数据
|
||||||
fetchStats()
|
fetchStats()
|
||||||
|
fetchConfigs()
|
||||||
|
|
||||||
// 设置定时刷新
|
// 设置定时刷新
|
||||||
const interval = setInterval(fetchStats, 5000)
|
const interval = setInterval(fetchStats, 5000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [fetchStats])
|
}, [fetchStats, fetchConfigs])
|
||||||
|
|
||||||
const handleToggleCache = async (type: "proxy" | "mirror" | "fixedPath", enabled: boolean) => {
|
const handleToggleCache = async (type: "proxy" | "mirror" | "fixedPath", enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
@ -91,6 +122,31 @@ export default function CachePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUpdateConfig = async (type: "proxy" | "mirror" | "fixedPath", config: CacheConfig) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/api/cache/config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type, config }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("更新缓存配置失败")
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "成功",
|
||||||
|
description: "缓存配置已更新",
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchConfigs()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: error instanceof Error ? error.message : "更新缓存配置失败",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleClearCache = async (type: "proxy" | "mirror" | "fixedPath" | "all") => {
|
const handleClearCache = async (type: "proxy" | "mirror" | "fixedPath" | "all") => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/admin/api/cache/clear", {
|
const response = await fetch("/admin/api/cache/clear", {
|
||||||
@ -116,6 +172,61 @@ export default function CachePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderCacheConfig = (type: "proxy" | "mirror" | "fixedPath") => {
|
||||||
|
if (!configs) return null
|
||||||
|
|
||||||
|
const config = configs[type]
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<h3 className="text-sm font-medium">缓存配置</h3>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
|
<Label htmlFor={`${type}-max-age`}>最大缓存时间(分钟)</Label>
|
||||||
|
<Input
|
||||||
|
id={`${type}-max-age`}
|
||||||
|
type="number"
|
||||||
|
value={config.max_age}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newConfigs = { ...configs }
|
||||||
|
newConfigs[type].max_age = parseInt(e.target.value)
|
||||||
|
setConfigs(newConfigs)
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateConfig(type, config)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
|
<Label htmlFor={`${type}-cleanup-tick`}>清理间隔(分钟)</Label>
|
||||||
|
<Input
|
||||||
|
id={`${type}-cleanup-tick`}
|
||||||
|
type="number"
|
||||||
|
value={config.cleanup_tick}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newConfigs = { ...configs }
|
||||||
|
newConfigs[type].cleanup_tick = parseInt(e.target.value)
|
||||||
|
setConfigs(newConfigs)
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateConfig(type, config)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
|
<Label htmlFor={`${type}-max-cache-size`}>最大缓存大小(GB)</Label>
|
||||||
|
<Input
|
||||||
|
id={`${type}-max-cache-size`}
|
||||||
|
type="number"
|
||||||
|
value={config.max_cache_size}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newConfigs = { ...configs }
|
||||||
|
newConfigs[type].max_cache_size = parseInt(e.target.value)
|
||||||
|
setConfigs(newConfigs)
|
||||||
|
}}
|
||||||
|
onBlur={() => handleUpdateConfig(type, config)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||||
@ -181,6 +292,7 @@ export default function CachePage() {
|
|||||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd>
|
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{renderCacheConfig("proxy")}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -229,6 +341,7 @@ export default function CachePage() {
|
|||||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
|
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{renderCacheConfig("mirror")}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -277,6 +390,7 @@ export default function CachePage() {
|
|||||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.fixedPath.bytes_saved ?? 0)}</dd>
|
<dd className="text-sm text-gray-900">{formatBytes(stats?.fixedPath.bytes_saved ?? 0)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{renderCacheConfig("fixedPath")}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,24 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState, useCallback } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
} from "recharts"
|
|
||||||
|
|
||||||
interface Metrics {
|
interface Metrics {
|
||||||
uptime: string
|
uptime: string
|
||||||
@ -29,6 +14,7 @@ interface Metrics {
|
|||||||
memory_usage: string
|
memory_usage: string
|
||||||
avg_response_time: string
|
avg_response_time: string
|
||||||
requests_per_second: number
|
requests_per_second: number
|
||||||
|
bytes_per_second: number
|
||||||
status_code_stats: Record<string, number>
|
status_code_stats: Record<string, number>
|
||||||
top_paths: Array<{
|
top_paths: Array<{
|
||||||
path: string
|
path: string
|
||||||
@ -60,9 +46,6 @@ interface Metrics {
|
|||||||
total_bytes: number
|
total_bytes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 颜色常量
|
|
||||||
const COLORS = ['#0088FE', '#FF8042', '#00C49F', '#FFBB28', '#FF0000']
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -70,7 +53,7 @@ export default function DashboardPage() {
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchMetrics = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("token")
|
const token = localStorage.getItem("token")
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@ -108,16 +91,13 @@ export default function DashboardPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [router, toast])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 立即获取一次数据
|
|
||||||
fetchMetrics()
|
fetchMetrics()
|
||||||
|
|
||||||
// 设置定时刷新
|
|
||||||
const interval = setInterval(fetchMetrics, 5000)
|
const interval = setInterval(fetchMetrics, 5000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [fetchMetrics])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -176,6 +156,14 @@ export default function DashboardPage() {
|
|||||||
<div className="text-sm font-medium text-gray-500">错误数</div>
|
<div className="text-sm font-medium text-gray-500">错误数</div>
|
||||||
<div className="text-lg font-semibold">{metrics.total_errors}</div>
|
<div className="text-lg font-semibold">{metrics.total_errors}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">总传输数据</div>
|
||||||
|
<div className="text-lg font-semibold">{formatBytes(metrics.total_bytes)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">每秒传输数据</div>
|
||||||
|
<div className="text-lg font-semibold">{formatBytes(metrics.bytes_per_second)}/s</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -307,145 +295,6 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>延迟统计</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium text-gray-500">最小响应时间</div>
|
|
||||||
<div className="text-lg font-semibold">{metrics?.latency_stats?.min}</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium text-gray-500">最大响应时间</div>
|
|
||||||
<div className="text-lg font-semibold">{metrics?.latency_stats?.max}</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<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="h-80 mt-6">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
|
||||||
data={Object.entries(metrics?.latency_stats?.distribution || {}).map(([name, value]) => ({
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
}))}
|
|
||||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="name" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="value" name="请求数" fill="#8884d8" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>错误分布</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={[
|
|
||||||
{ name: '客户端错误', value: metrics?.error_stats?.client_errors || 0 },
|
|
||||||
{ name: '服务器错误', value: metrics?.error_stats?.server_errors || 0 },
|
|
||||||
]}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
|
||||||
outerRadius={80}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{[0, 1].map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>错误类型统计</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
|
||||||
data={Object.entries(metrics?.error_stats?.types || {}).map(([name, value]) => ({
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
}))}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{ top: 5, right: 30, left: 120, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis type="number" />
|
|
||||||
<YAxis type="category" dataKey="name" width={120} />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="value" name="错误次数" fill={COLORS[1]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>带宽统计</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium text-gray-500">当前带宽</div>
|
|
||||||
<div className="text-lg font-semibold">{metrics?.current_bandwidth}</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium text-gray-500">总传输数据</div>
|
|
||||||
<div className="text-lg font-semibold">{formatBytes(metrics?.total_bytes || 0)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart
|
|
||||||
data={Object.entries(metrics?.bandwidth_history || {}).map(([time, value]) => ({
|
|
||||||
time,
|
|
||||||
value: parseFloat(value.split(' ')[0]),
|
|
||||||
unit: value.split(' ')[1],
|
|
||||||
}))}
|
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="time" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip formatter={(value, name, props) => [`${value} ${props.payload.unit}`, '带宽']} />
|
|
||||||
<Legend />
|
|
||||||
<Line type="monotone" dataKey="value" name="带宽" stroke="#8884d8" />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
26
web/components/ui/label.tsx
Normal file
26
web/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
24
web/package-lock.json
generated
24
web/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
@ -988,6 +989,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-label": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-portal": {
|
"node_modules/@radix-ui/react-portal": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user