mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 08:31:55 +08:00
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:
parent
eabb1f1f9a
commit
3e5950e3f6
@ -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{
|
// getBuffer 获取缓冲区
|
||||||
New: func() interface{} {
|
func getBuffer() (*bytes.Buffer, func()) {
|
||||||
return bytes.NewBuffer(make([]byte, mediumBufferSize))
|
buf := bufferPool.Get().(*bytes.Buffer)
|
||||||
},
|
buf.Reset()
|
||||||
}
|
|
||||||
|
|
||||||
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() // 重置缓冲区
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
|
||||||
}
|
|
@ -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,7 +45,23 @@ 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)
|
||||||
@ -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
365
web/components/ui/chart.tsx
Normal 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
355
web/package-lock.json
generated
@ -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",
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user