mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 08:31:55 +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
152
internal/cache/manager.go
vendored
152
internal/cache/manager.go
vendored
@ -72,10 +72,12 @@ type CacheManager struct {
|
||||
maxAge time.Duration
|
||||
cleanupTick time.Duration
|
||||
maxCacheSize int64
|
||||
enabled atomic.Bool // 缓存开关
|
||||
hitCount atomic.Int64 // 命中计数
|
||||
missCount atomic.Int64 // 未命中计数
|
||||
bytesSaved atomic.Int64 // 节省的带宽
|
||||
enabled atomic.Bool // 缓存开关
|
||||
hitCount atomic.Int64 // 命中计数
|
||||
missCount atomic.Int64 // 未命中计数
|
||||
bytesSaved atomic.Int64 // 节省的带宽
|
||||
cleanupTimer *time.Ticker // 添加清理定时器
|
||||
stopCleanup chan struct{} // 添加停止信号通道
|
||||
}
|
||||
|
||||
// NewCacheManager 创建新的缓存管理器
|
||||
@ -89,12 +91,13 @@ func NewCacheManager(cacheDir string) (*CacheManager, error) {
|
||||
maxAge: 30 * time.Minute,
|
||||
cleanupTick: 5 * time.Minute,
|
||||
maxCacheSize: 10 * 1024 * 1024 * 1024, // 10GB
|
||||
stopCleanup: make(chan struct{}),
|
||||
}
|
||||
|
||||
cm.enabled.Store(true) // 默认启用缓存
|
||||
|
||||
// 启动清理协程
|
||||
go cm.cleanup()
|
||||
cm.startCleanup()
|
||||
|
||||
return cm, nil
|
||||
}
|
||||
@ -214,60 +217,57 @@ func (cm *CacheManager) Put(key CacheKey, resp *http.Response, body []byte) (*Ca
|
||||
|
||||
// cleanup 定期清理过期的缓存项
|
||||
func (cm *CacheManager) cleanup() {
|
||||
ticker := time.NewTicker(cm.cleanupTick)
|
||||
for range ticker.C {
|
||||
var totalSize int64
|
||||
var keysToDelete []CacheKey
|
||||
var totalSize int64
|
||||
var keysToDelete []CacheKey
|
||||
|
||||
// 收集需要删除的键和计算总大小
|
||||
// 收集需要删除的键和计算总大小
|
||||
cm.items.Range(func(k, v interface{}) bool {
|
||||
key := k.(CacheKey)
|
||||
item := v.(*CacheItem)
|
||||
totalSize += item.Size
|
||||
|
||||
if time.Since(item.LastAccess) > cm.maxAge {
|
||||
keysToDelete = append(keysToDelete, key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 如果总大小超过限制,按最后访问时间排序删除
|
||||
if totalSize > cm.maxCacheSize {
|
||||
var items []*CacheItem
|
||||
cm.items.Range(func(k, v interface{}) bool {
|
||||
key := k.(CacheKey)
|
||||
item := v.(*CacheItem)
|
||||
totalSize += item.Size
|
||||
|
||||
if time.Since(item.LastAccess) > cm.maxAge {
|
||||
keysToDelete = append(keysToDelete, key)
|
||||
}
|
||||
items = append(items, v.(*CacheItem))
|
||||
return true
|
||||
})
|
||||
|
||||
// 如果总大小超过限制,按最后访问时间排序删除
|
||||
if totalSize > cm.maxCacheSize {
|
||||
var items []*CacheItem
|
||||
// 按最后访问时间排序
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].LastAccess.Before(items[j].LastAccess)
|
||||
})
|
||||
|
||||
// 删除最旧的项直到总大小小于限制
|
||||
for _, item := range items {
|
||||
if totalSize <= cm.maxCacheSize {
|
||||
break
|
||||
}
|
||||
cm.items.Range(func(k, v interface{}) bool {
|
||||
items = append(items, v.(*CacheItem))
|
||||
if v.(*CacheItem) == item {
|
||||
keysToDelete = append(keysToDelete, k.(CacheKey))
|
||||
totalSize -= item.Size
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 按最后访问时间排序
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].LastAccess.Before(items[j].LastAccess)
|
||||
})
|
||||
|
||||
// 删除最旧的项直到总大小小于限制
|
||||
for _, item := range items {
|
||||
if totalSize <= cm.maxCacheSize {
|
||||
break
|
||||
}
|
||||
cm.items.Range(func(k, v interface{}) bool {
|
||||
if v.(*CacheItem) == item {
|
||||
keysToDelete = append(keysToDelete, k.(CacheKey))
|
||||
totalSize -= item.Size
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除过期和超出大小限制的缓存项
|
||||
for _, key := range keysToDelete {
|
||||
if item, ok := cm.items.Load(key); ok {
|
||||
cacheItem := item.(*CacheItem)
|
||||
os.Remove(cacheItem.FilePath)
|
||||
cm.items.Delete(key)
|
||||
log.Printf("[Cache] Removed expired item: %s", key.URL)
|
||||
}
|
||||
// 删除过期和超出大小限制的缓存项
|
||||
for _, key := range keysToDelete {
|
||||
if item, ok := cm.items.Load(key); ok {
|
||||
cacheItem := item.(*CacheItem)
|
||||
os.Remove(cacheItem.FilePath)
|
||||
cm.items.Delete(key)
|
||||
log.Printf("[Cache] Removed expired item: %s", key.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
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 获取缓存统计信息
|
||||
func (h *CacheAdminHandler) GetCacheStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@ -37,6 +44,61 @@ func (h *CacheAdminHandler) GetCacheStats(w http.ResponseWriter, r *http.Request
|
||||
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 设置缓存开关状态
|
||||
func (h *CacheAdminHandler) SetCacheEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
|
@ -194,6 +194,21 @@ func (c *Collector) RecordRequest(path string, status int, latency time.Duration
|
||||
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 获取统计数据
|
||||
func (c *Collector) GetStats() map[string]interface{} {
|
||||
var mem runtime.MemStats
|
||||
@ -274,7 +289,7 @@ func (c *Collector) GetStats() 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),
|
||||
"total_requests": atomic.LoadInt64(&c.totalRequests),
|
||||
"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 { useToast } from "@/components/ui/use-toast"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
interface CacheStats {
|
||||
total_items: number
|
||||
@ -16,12 +18,24 @@ interface CacheStats {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface CacheConfig {
|
||||
max_age: number
|
||||
cleanup_tick: number
|
||||
max_cache_size: number
|
||||
}
|
||||
|
||||
interface CacheData {
|
||||
proxy: CacheStats
|
||||
mirror: CacheStats
|
||||
fixedPath: CacheStats
|
||||
}
|
||||
|
||||
interface CacheConfigs {
|
||||
proxy: CacheConfig
|
||||
mirror: CacheConfig
|
||||
fixedPath: CacheConfig
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = bytes
|
||||
@ -37,6 +51,7 @@ function formatBytes(bytes: number) {
|
||||
|
||||
export default function CachePage() {
|
||||
const [stats, setStats] = useState<CacheData | null>(null)
|
||||
const [configs, setConfigs] = useState<CacheConfigs | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { toast } = useToast()
|
||||
|
||||
@ -57,14 +72,30 @@ export default function CachePage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
// 立即获取一次数据
|
||||
fetchStats()
|
||||
fetchConfigs()
|
||||
|
||||
// 设置定时刷新
|
||||
const interval = setInterval(fetchStats, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStats])
|
||||
}, [fetchStats, fetchConfigs])
|
||||
|
||||
const handleToggleCache = async (type: "proxy" | "mirror" | "fixedPath", enabled: boolean) => {
|
||||
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") => {
|
||||
try {
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
</dl>
|
||||
{renderCacheConfig("proxy")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -229,6 +341,7 @@ export default function CachePage() {
|
||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{renderCacheConfig("mirror")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -277,6 +390,7 @@ export default function CachePage() {
|
||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.fixedPath.bytes_saved ?? 0)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{renderCacheConfig("fixedPath")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
@ -1,24 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
LineChart,
|
||||
Line,
|
||||
} from "recharts"
|
||||
|
||||
interface Metrics {
|
||||
uptime: string
|
||||
@ -29,6 +14,7 @@ interface Metrics {
|
||||
memory_usage: string
|
||||
avg_response_time: string
|
||||
requests_per_second: number
|
||||
bytes_per_second: number
|
||||
status_code_stats: Record<string, number>
|
||||
top_paths: Array<{
|
||||
path: string
|
||||
@ -60,9 +46,6 @@ interface Metrics {
|
||||
total_bytes: number
|
||||
}
|
||||
|
||||
// 颜色常量
|
||||
const COLORS = ['#0088FE', '#FF8042', '#00C49F', '#FFBB28', '#FF0000']
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -70,7 +53,7 @@ export default function DashboardPage() {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
const fetchMetrics = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) {
|
||||
@ -108,16 +91,13 @@ export default function DashboardPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [router, toast])
|
||||
|
||||
useEffect(() => {
|
||||
// 立即获取一次数据
|
||||
fetchMetrics()
|
||||
|
||||
// 设置定时刷新
|
||||
const interval = setInterval(fetchMetrics, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
}, [fetchMetrics])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -176,6 +156,14 @@ export default function DashboardPage() {
|
||||
<div className="text-sm font-medium text-gray-500">错误数</div>
|
||||
<div className="text-lg font-semibold">{metrics.total_errors}</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -307,145 +295,6 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
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",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
|
||||
|
@ -9,6 +9,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
|
Loading…
x
Reference in New Issue
Block a user