feat(metrics,web): Enhance dashboard with advanced metrics visualization

- Integrate Recharts library for interactive data visualization
- Add detailed latency distribution charts and error type analysis
- Implement bandwidth and performance metrics graphs
- Update metrics collection to support more granular statistical tracking
- Modify frontend API routes to remove /admin prefix
- Improve metrics display with responsive and informative charts
This commit is contained in:
wood chen 2025-02-15 16:23:20 +08:00
parent eabb1f1f9a
commit 3e5950e3f6
7 changed files with 1098 additions and 169 deletions

View File

@ -21,76 +21,40 @@ import (
) )
const ( const (
smallBufferSize = 4 * 1024 // 4KB // 缓冲区大小
mediumBufferSize = 32 * 1024 // 32KB defaultBufferSize = 32 * 1024 // 32KB
largeBufferSize = 64 * 1024 // 64KB
// 超时时间常量 // 超时时间常量
clientConnTimeout = 5 * time.Second // 客户端连接超时 clientConnTimeout = 10 * time.Second
proxyRespTimeout = 30 * time.Second // 代理响应超时 proxyRespTimeout = 60 * time.Second
backendServTimeout = 20 * time.Second // 后端服务超时 backendServTimeout = 40 * time.Second
idleConnTimeout = 120 * time.Second // 空闲连接超时 idleConnTimeout = 120 * time.Second
tlsHandshakeTimeout = 10 * time.Second // TLS握手超时 tlsHandshakeTimeout = 10 * time.Second
// 限流相关常量 // 限流相关常量
globalRateLimit = 1000 // 全局每秒请求数限制 globalRateLimit = 2000
globalBurstLimit = 200 // 全局突发请求数限制 globalBurstLimit = 500
perHostRateLimit = 100 // 每个host每秒请求数限制 perHostRateLimit = 200
perHostBurstLimit = 50 // 每个host突发请求数限制 perHostBurstLimit = 100
perIPRateLimit = 20 // 每个IP每秒请求数限制 perIPRateLimit = 50
perIPBurstLimit = 10 // 每个IP突发请求数限制 perIPBurstLimit = 20
cleanupInterval = 10 * time.Minute // 清理过期限流器的间隔 cleanupInterval = 10 * time.Minute
) )
// 定义不同大小的缓冲池 // 统一的缓冲池
var ( var bufferPool = sync.Pool{
smallBufferPool = sync.Pool{
New: func() interface{} { New: func() interface{} {
return bytes.NewBuffer(make([]byte, smallBufferSize)) return bytes.NewBuffer(make([]byte, defaultBufferSize))
}, },
} }
mediumBufferPool = sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(make([]byte, mediumBufferSize))
},
}
largeBufferPool = sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(make([]byte, largeBufferSize))
},
}
// 用于大文件传输的字节切片池
byteSlicePool = sync.Pool{
New: func() interface{} {
b := make([]byte, largeBufferSize)
return &b
},
}
)
// getBuffer 根据大小选择合适的缓冲池
func getBuffer(size int64) (*bytes.Buffer, func()) {
var buf *bytes.Buffer
var pool *sync.Pool
switch {
case size <= smallBufferSize:
pool = &smallBufferPool
case size <= mediumBufferSize:
pool = &mediumBufferPool
default:
pool = &largeBufferPool
}
buf = pool.Get().(*bytes.Buffer)
buf.Reset() // 重置缓冲区
// getBuffer 获取缓冲区
func getBuffer() (*bytes.Buffer, func()) {
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
return buf, func() { return buf, func() {
if buf != nil { if buf != nil {
pool.Put(buf) bufferPool.Put(buf)
} }
} }
} }
@ -238,12 +202,12 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
transport := &http.Transport{ transport := &http.Transport{
DialContext: dialer.DialContext, DialContext: dialer.DialContext,
MaxIdleConns: 300, MaxIdleConns: 1000, // 增加最大空闲连接数
MaxIdleConnsPerHost: 50, MaxIdleConnsPerHost: 100, // 增加每个主机的最大空闲连接数
IdleConnTimeout: idleConnTimeout, IdleConnTimeout: idleConnTimeout,
TLSHandshakeTimeout: tlsHandshakeTimeout, TLSHandshakeTimeout: tlsHandshakeTimeout,
ExpectContinueTimeout: 1 * time.Second, ExpectContinueTimeout: 1 * time.Second,
MaxConnsPerHost: 100, MaxConnsPerHost: 200, // 增加每个主机的最大连接数
DisableKeepAlives: false, DisableKeepAlives: false,
DisableCompression: false, DisableCompression: false,
ForceAttemptHTTP2: true, ForceAttemptHTTP2: true,
@ -309,16 +273,47 @@ func (h *ProxyHandler) SetErrorHandler(handler ErrorHandler) {
} }
} }
// copyResponse 使用零拷贝方式传输数据 // copyResponse 使用缓冲方式传输数据
func copyResponse(dst io.Writer, src io.Reader, flusher http.Flusher) (int64, error) { func copyResponse(dst io.Writer, src io.Reader, flusher http.Flusher) (int64, error) {
buf := byteSlicePool.Get().(*[]byte) buf := bufferPool.Get().(*bytes.Buffer)
defer byteSlicePool.Put(buf) defer bufferPool.Put(buf)
buf.Reset()
written, err := io.CopyBuffer(dst, src, *buf) var written int64
if err == nil && flusher != nil { for {
// 清空缓冲区
buf.Reset()
// 读取数据到缓冲区
_, er := io.CopyN(buf, src, defaultBufferSize)
if er != nil && er != io.EOF {
return written, er
}
// 如果有数据,写入目标
if buf.Len() > 0 {
nw, ew := dst.Write(buf.Bytes())
if ew != nil {
return written, ew
}
written += int64(nw)
// 定期刷新缓冲区
if flusher != nil && written%(1024*1024) == 0 { // 每1MB刷新一次
flusher.Flush() flusher.Flush()
} }
return written, err }
if er == io.EOF {
break
}
}
// 最后一次刷新
if flusher != nil {
flusher.Flush()
}
return written, nil
} }
func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@ -414,11 +409,6 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// 添加请求追踪标识
requestID := utils.GenerateRequestID()
proxyReq.Header.Set("X-Request-ID", requestID)
w.Header().Set("X-Request-ID", requestID)
// 复制并处理请求头 // 复制并处理请求头
copyHeader(proxyReq.Header, r.Header) copyHeader(proxyReq.Header, r.Header)
@ -492,13 +482,15 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
contentLength := resp.ContentLength contentLength := resp.ContentLength
if contentLength > 0 && contentLength < 1<<20 { // 1MB 以下的小响应 if contentLength > 0 && contentLength < 1<<20 { // 1MB 以下的小响应
// 获取合适大小的缓冲区 // 获取合适大小的缓冲区
buf, putBuffer := getBuffer(contentLength) buf, putBuffer := getBuffer()
defer putBuffer() defer putBuffer()
// 使用缓冲区读取响应 // 使用缓冲区读取响应
_, err := io.Copy(buf, resp.Body) _, err := io.Copy(buf, resp.Body)
if err != nil { if err != nil {
if !isConnectionClosed(err) {
h.errorHandler(w, r, fmt.Errorf("error reading response: %v", err)) h.errorHandler(w, r, fmt.Errorf("error reading response: %v", err))
}
return return
} }
@ -528,17 +520,6 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
// 记录访问日志 // 记录访问日志
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | %-50s -> %s",
r.Method, // HTTP方法左对齐占6位
resp.StatusCode, // 状态码占3位
time.Since(start), // 处理时间占12位
utils.GetClientIP(r), // IP地址占15位
utils.FormatBytes(bytesCopied), // 传输大小占10位
utils.GetRequestSource(r), // 请求来源
r.URL.Path, // 请求路径左对齐占50位
targetURL, // 目标URL
)
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), bytesCopied, utils.GetClientIP(r), r) collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), bytesCopied, utils.GetClientIP(r), r)
} }
} }

View File

@ -3,6 +3,7 @@ package metrics
import ( import (
"fmt" "fmt"
"log" "log"
"math"
"net/http" "net/http"
"proxy-go/internal/config" "proxy-go/internal/config"
"proxy-go/internal/models" "proxy-go/internal/models"
@ -22,10 +23,21 @@ type Collector struct {
totalErrors int64 totalErrors int64
totalBytes int64 totalBytes int64
latencySum int64 latencySum int64
maxLatency int64 // 最大响应时间
minLatency int64 // 最小响应时间
clientErrors int64 // 4xx错误
serverErrors int64 // 5xx错误
pathStats sync.Map pathStats sync.Map
statusCodeStats sync.Map statusCodeStats sync.Map
recentRequests *models.RequestQueue latencyBuckets sync.Map // 响应时间分布
bandwidthStats sync.Map // 带宽统计
errorTypes sync.Map // 错误类型统计
recentRequests []models.RequestLog
recentRequestsMutex sync.RWMutex
pathStatsMutex sync.RWMutex
config *config.Config config *config.Config
lastMinute time.Time // 用于计算每分钟带宽
minuteBytes int64 // 当前分钟的字节数
} }
var ( var (
@ -38,9 +50,18 @@ func InitCollector(cfg *config.Config) error {
once.Do(func() { once.Do(func() {
instance = &Collector{ instance = &Collector{
startTime: time.Now(), startTime: time.Now(),
recentRequests: models.NewRequestQueue(1000), lastMinute: time.Now(),
recentRequests: make([]models.RequestLog, 0, 1000),
config: cfg, config: cfg,
minLatency: math.MaxInt64, // 初始化为最大值
} }
// 初始化延迟分布桶
instance.latencyBuckets.Store("<10ms", new(int64))
instance.latencyBuckets.Store("10-50ms", new(int64))
instance.latencyBuckets.Store("50-200ms", new(int64))
instance.latencyBuckets.Store("200-1000ms", new(int64))
instance.latencyBuckets.Store(">1s", new(int64))
}) })
return nil return nil
} }
@ -62,52 +83,85 @@ func (c *Collector) EndRequest() {
// RecordRequest 记录请求 // RecordRequest 记录请求
func (c *Collector) RecordRequest(path string, status int, latency time.Duration, bytes int64, clientIP string, r *http.Request) { func (c *Collector) RecordRequest(path string, status int, latency time.Duration, bytes int64, clientIP string, r *http.Request) {
// 批量更新基础指标
atomic.AddInt64(&c.totalRequests, 1) atomic.AddInt64(&c.totalRequests, 1)
atomic.AddInt64(&c.totalBytes, bytes) atomic.AddInt64(&c.totalBytes, bytes)
atomic.AddInt64(&c.latencySum, int64(latency)) atomic.AddInt64(&c.latencySum, int64(latency))
if status >= 400 { // 更新延迟分布
atomic.AddInt64(&c.totalErrors, 1) latencyMs := latency.Milliseconds()
var bucketKey string
switch {
case latencyMs < 10:
bucketKey = "<10ms"
case latencyMs < 50:
bucketKey = "10-50ms"
case latencyMs < 200:
bucketKey = "50-200ms"
case latencyMs < 1000:
bucketKey = "200-1000ms"
default:
bucketKey = ">1s"
} }
if counter, ok := c.latencyBuckets.Load(bucketKey); ok {
// 更新状态码统计 atomic.AddInt64(counter.(*int64), 1)
statusKey := fmt.Sprintf("%d", status)
if value, ok := c.statusCodeStats.Load(statusKey); ok {
atomic.AddInt64(value.(*int64), 1)
} else { } else {
var count int64 = 1 var count int64 = 1
c.statusCodeStats.Store(statusKey, &count) c.latencyBuckets.Store(bucketKey, &count)
}
// 更新错误统计
if status >= 400 {
atomic.AddInt64(&c.totalErrors, 1)
if status >= 500 {
atomic.AddInt64(&c.serverErrors, 1)
} else {
atomic.AddInt64(&c.clientErrors, 1)
}
errKey := fmt.Sprintf("%d %s", status, http.StatusText(status))
if counter, ok := c.errorTypes.Load(errKey); ok {
atomic.AddInt64(counter.(*int64), 1)
} else {
var count int64 = 1
c.errorTypes.Store(errKey, &count)
}
} }
// 更新路径统计 // 更新路径统计
if pathStats, ok := c.pathStats.Load(path); ok { c.pathStatsMutex.Lock()
stats := pathStats.(models.PathMetrics) if value, ok := c.pathStats.Load(path); ok {
atomic.AddInt64(&stats.RequestCount, 1) stat := value.(models.PathMetrics)
atomic.AddInt64(&stat.RequestCount, 1)
if status >= 400 { if status >= 400 {
atomic.AddInt64(&stats.ErrorCount, 1) atomic.AddInt64(&stat.ErrorCount, 1)
} }
atomic.AddInt64(&stats.TotalLatency, int64(latency)) atomic.AddInt64(&stat.TotalLatency, int64(latency))
atomic.AddInt64(&stats.BytesTransferred, bytes) atomic.AddInt64(&stat.BytesTransferred, bytes)
} else { } else {
stats := models.PathMetrics{ c.pathStats.Store(path, &models.PathMetrics{
Path: path, Path: path,
RequestCount: 1, RequestCount: 1,
ErrorCount: int64(map[bool]int{true: 1, false: 0}[status >= 400]), ErrorCount: map[bool]int64{true: 1, false: 0}[status >= 400],
TotalLatency: int64(latency), TotalLatency: int64(latency),
BytesTransferred: bytes, BytesTransferred: bytes,
})
} }
c.pathStats.Store(path, stats) c.pathStatsMutex.Unlock()
}
// 记录最近请求 // 更新最近请求记录
c.recentRequests.Push(models.RequestLog{ c.recentRequestsMutex.Lock()
c.recentRequests = append([]models.RequestLog{{
Time: time.Now(), Time: time.Now(),
Path: path, Path: path,
Status: status, Status: status,
Latency: int64(latency), Latency: int64(latency),
BytesSent: bytes, BytesSent: bytes,
ClientIP: clientIP, ClientIP: clientIP,
}) }}, c.recentRequests...)
if len(c.recentRequests) > 100 { // 只保留最近100条记录
c.recentRequests = c.recentRequests[:100]
}
c.recentRequestsMutex.Unlock()
} }
// GetStats 获取统计数据 // GetStats 获取统计数据
@ -117,8 +171,9 @@ func (c *Collector) GetStats() map[string]interface{} {
// 计算平均延迟 // 计算平均延迟
avgLatency := float64(0) avgLatency := float64(0)
if c.totalRequests > 0 { totalReqs := atomic.LoadInt64(&c.totalRequests)
avgLatency = float64(c.latencySum) / float64(c.totalRequests) if totalReqs > 0 {
avgLatency = float64(atomic.LoadInt64(&c.latencySum)) / float64(totalReqs)
} }
// 收集状态码统计 // 收集状态码统计
@ -132,11 +187,15 @@ func (c *Collector) GetStats() map[string]interface{} {
var pathMetrics []models.PathMetrics var pathMetrics []models.PathMetrics
c.pathStats.Range(func(key, value interface{}) bool { c.pathStats.Range(func(key, value interface{}) bool {
stats := value.(models.PathMetrics) stats := value.(models.PathMetrics)
if stats.RequestCount > 0 {
avgLatencyMs := float64(stats.TotalLatency) / float64(stats.RequestCount) / float64(time.Millisecond)
stats.AvgLatency = fmt.Sprintf("%.2fms", avgLatencyMs)
}
pathMetrics = append(pathMetrics, stats) pathMetrics = append(pathMetrics, stats)
return true return true
}) })
// 按请求数排序 // 按请求数降序排序
sort.Slice(pathMetrics, func(i, j int) bool { sort.Slice(pathMetrics, func(i, j int) bool {
return pathMetrics[i].RequestCount > pathMetrics[j].RequestCount return pathMetrics[i].RequestCount > pathMetrics[j].RequestCount
}) })
@ -146,11 +205,34 @@ func (c *Collector) GetStats() map[string]interface{} {
pathMetrics = pathMetrics[:10] pathMetrics = pathMetrics[:10]
} }
// 计算每个路径的平均延迟 // 收集延迟分布
for i := range pathMetrics { latencyDistribution := make(map[string]int64)
if pathMetrics[i].RequestCount > 0 { c.latencyBuckets.Range(func(key, value interface{}) bool {
avgLatencyMs := float64(pathMetrics[i].TotalLatency) / float64(pathMetrics[i].RequestCount) / float64(time.Millisecond) latencyDistribution[key.(string)] = atomic.LoadInt64(value.(*int64))
pathMetrics[i].AvgLatency = fmt.Sprintf("%.2fms", avgLatencyMs) return true
})
// 收集错误类型统计
errorTypeStats := make(map[string]int64)
c.errorTypes.Range(func(key, value interface{}) bool {
errorTypeStats[key.(string)] = atomic.LoadInt64(value.(*int64))
return true
})
// 收集最近5分钟的带宽统计
bandwidthHistory := make(map[string]string)
var times []string
c.bandwidthStats.Range(func(key, value interface{}) bool {
times = append(times, key.(string))
return true
})
sort.Strings(times)
if len(times) > 5 {
times = times[len(times)-5:]
}
for _, t := range times {
if bytes, ok := c.bandwidthStats.Load(t); ok {
bandwidthHistory[t] = utils.FormatBytes(atomic.LoadInt64(bytes.(*int64))) + "/min"
} }
} }
@ -165,7 +247,19 @@ func (c *Collector) GetStats() map[string]interface{} {
"avg_response_time": fmt.Sprintf("%.2fms", avgLatency/float64(time.Millisecond)), "avg_response_time": fmt.Sprintf("%.2fms", avgLatency/float64(time.Millisecond)),
"status_code_stats": statusCodeStats, "status_code_stats": statusCodeStats,
"top_paths": pathMetrics, "top_paths": pathMetrics,
"recent_requests": c.recentRequests.GetAll(), "recent_requests": c.recentRequests,
"latency_stats": map[string]interface{}{
"min": fmt.Sprintf("%.2fms", float64(atomic.LoadInt64(&c.minLatency))/float64(time.Millisecond)),
"max": fmt.Sprintf("%.2fms", float64(atomic.LoadInt64(&c.maxLatency))/float64(time.Millisecond)),
"distribution": latencyDistribution,
},
"error_stats": map[string]interface{}{
"client_errors": atomic.LoadInt64(&c.clientErrors),
"server_errors": atomic.LoadInt64(&c.serverErrors),
"types": errorTypeStats,
},
"bandwidth_history": bandwidthHistory,
"current_bandwidth": utils.FormatBytes(atomic.LoadInt64(&c.minuteBytes)) + "/min",
} }
} }
@ -190,20 +284,20 @@ func (c *Collector) LoadRecentStats() error {
// validateLoadedData 验证当前数据的有效性 // validateLoadedData 验证当前数据的有效性
func (c *Collector) validateLoadedData() error { func (c *Collector) validateLoadedData() error {
// 验证基础指标 // 验证基础指标
if atomic.LoadInt64(&c.totalRequests) < 0 || if c.totalRequests < 0 ||
atomic.LoadInt64(&c.totalErrors) < 0 || c.totalErrors < 0 ||
atomic.LoadInt64(&c.totalBytes) < 0 { c.totalBytes < 0 {
return fmt.Errorf("invalid stats values") return fmt.Errorf("invalid stats values")
} }
// 验证错误数不能大于总请求数 // 验证错误数不能大于总请求数
if atomic.LoadInt64(&c.totalErrors) > atomic.LoadInt64(&c.totalRequests) { if c.totalErrors > c.totalRequests {
return fmt.Errorf("total errors exceeds total requests") return fmt.Errorf("total errors exceeds total requests")
} }
// 验证状态码统计 // 验证状态码统计
c.statusCodeStats.Range(func(key, value interface{}) bool { c.statusCodeStats.Range(func(key, value interface{}) bool {
return atomic.LoadInt64(value.(*int64)) >= 0 return value.(int64) >= 0
}) })
// 验证路径统计 // 验证路径统计
@ -218,7 +312,7 @@ func (c *Collector) validateLoadedData() error {
}) })
// 验证总数一致性 // 验证总数一致性
if totalPathRequests > atomic.LoadInt64(&c.totalRequests) { if totalPathRequests > c.totalRequests {
return fmt.Errorf("path stats total exceeds total requests") return fmt.Errorf("path stats total exceeds total requests")
} }
@ -235,7 +329,7 @@ func (c *Collector) GetLastSaveTime() time.Time {
// CheckDataConsistency 实现 interfaces.MetricsCollector 接口 // CheckDataConsistency 实现 interfaces.MetricsCollector 接口
func (c *Collector) CheckDataConsistency() error { func (c *Collector) CheckDataConsistency() error {
// 简单的数据验证 // 简单的数据验证
if atomic.LoadInt64(&c.totalErrors) > atomic.LoadInt64(&c.totalRequests) { if c.totalErrors > c.totalRequests {
return fmt.Errorf("total errors exceeds total requests") return fmt.Errorf("total errors exceeds total requests")
} }
return nil return nil

View File

@ -1,24 +0,0 @@
package metrics
import (
"sync/atomic"
"time"
)
// RequestLog 记录单个请求的信息
type RequestLog struct {
Time time.Time
Path string
Status int
Latency time.Duration
BytesSent int64
ClientIP string
}
// PathStats 记录路径统计信息
type PathStats struct {
Requests atomic.Int64
Errors atomic.Int64
Bytes atomic.Int64
LatencySum atomic.Int64
}

View File

@ -4,6 +4,21 @@ import { useEffect, useState } 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
@ -30,8 +45,24 @@ interface Metrics {
BytesSent: number BytesSent: number
ClientIP: string ClientIP: string
}> }>
latency_stats: {
min: string
max: string
distribution: Record<string, number>
}
error_stats: {
client_errors: number
server_errors: number
types: Record<string, number>
}
bandwidth_history: Record<string, string>
current_bandwidth: string
total_bytes: number
} }
// 颜色常量
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
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)
@ -251,8 +282,10 @@ export default function DashboardPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(metrics.recent_requests || []).map((req, index) => ( {(metrics.recent_requests || [])
<tr key={index} className="border-b"> .slice(0, 20) // 只显示最近20条记录
.map((req, index) => (
<tr key={index} className="border-b hover:bg-gray-50">
<td className="p-2">{formatDate(req.Time)}</td> <td className="p-2">{formatDate(req.Time)}</td>
<td className="p-2 max-w-xs truncate">{req.Path}</td> <td className="p-2 max-w-xs truncate">{req.Path}</td>
<td className="p-2"> <td className="p-2">
@ -274,6 +307,144 @@ 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: 100, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis type="category" dataKey="name" />
<Tooltip />
<Bar dataKey="value" fill="#FF8042" />
</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>
) )
} }

365
web/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

355
web/package-lock.json generated
View File

@ -1,11 +1,11 @@
{ {
"name": "my-app", "name": "web",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "my-app", "name": "web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
@ -16,6 +16,7 @@
"next": "15.1.0", "next": "15.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^2.15.1",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
@ -43,6 +44,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
@ -1215,6 +1228,69 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@ -2131,9 +2207,129 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -2213,6 +2409,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -2291,6 +2493,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2944,6 +3156,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -2951,6 +3169,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@ -3481,6 +3708,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -3952,7 +4188,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@ -4096,6 +4331,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -4107,7 +4348,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@ -4790,7 +5030,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@ -4853,9 +5092,39 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -4877,6 +5146,44 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/recharts": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz",
"integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -4900,6 +5207,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@ -5717,6 +6030,12 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.10", "version": "0.2.10",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz",
@ -5956,6 +6275,28 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -17,6 +17,7 @@
"next": "15.1.0", "next": "15.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^2.15.1",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },