From 3e5950e3f6c7de52326ade49a09cf6986b6882a2 Mon Sep 17 00:00:00 2001 From: wood chen Date: Sat, 15 Feb 2025 16:23:20 +0800 Subject: [PATCH] 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 --- internal/handler/proxy.go | 157 +++++++-------- internal/metrics/collector.go | 190 +++++++++++++----- internal/metrics/types.go | 24 --- web/app/dashboard/page.tsx | 175 +++++++++++++++- web/components/ui/chart.tsx | 365 ++++++++++++++++++++++++++++++++++ web/package-lock.json | 355 ++++++++++++++++++++++++++++++++- web/package.json | 1 + 7 files changed, 1098 insertions(+), 169 deletions(-) delete mode 100644 internal/metrics/types.go create mode 100644 web/components/ui/chart.tsx diff --git a/internal/handler/proxy.go b/internal/handler/proxy.go index 3778600..ac25453 100644 --- a/internal/handler/proxy.go +++ b/internal/handler/proxy.go @@ -21,76 +21,40 @@ import ( ) const ( - smallBufferSize = 4 * 1024 // 4KB - mediumBufferSize = 32 * 1024 // 32KB - largeBufferSize = 64 * 1024 // 64KB + // 缓冲区大小 + defaultBufferSize = 32 * 1024 // 32KB // 超时时间常量 - clientConnTimeout = 5 * time.Second // 客户端连接超时 - proxyRespTimeout = 30 * time.Second // 代理响应超时 - backendServTimeout = 20 * time.Second // 后端服务超时 - idleConnTimeout = 120 * time.Second // 空闲连接超时 - tlsHandshakeTimeout = 10 * time.Second // TLS握手超时 + clientConnTimeout = 10 * time.Second + proxyRespTimeout = 60 * time.Second + backendServTimeout = 40 * time.Second + idleConnTimeout = 120 * time.Second + tlsHandshakeTimeout = 10 * time.Second // 限流相关常量 - globalRateLimit = 1000 // 全局每秒请求数限制 - globalBurstLimit = 200 // 全局突发请求数限制 - perHostRateLimit = 100 // 每个host每秒请求数限制 - perHostBurstLimit = 50 // 每个host突发请求数限制 - perIPRateLimit = 20 // 每个IP每秒请求数限制 - perIPBurstLimit = 10 // 每个IP突发请求数限制 - cleanupInterval = 10 * time.Minute // 清理过期限流器的间隔 + globalRateLimit = 2000 + globalBurstLimit = 500 + perHostRateLimit = 200 + perHostBurstLimit = 100 + perIPRateLimit = 50 + perIPBurstLimit = 20 + cleanupInterval = 10 * time.Minute ) -// 定义不同大小的缓冲池 -var ( - smallBufferPool = sync.Pool{ - New: func() interface{} { - return bytes.NewBuffer(make([]byte, smallBufferSize)) - }, - } - - 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() // 重置缓冲区 +// 统一的缓冲池 +var bufferPool = sync.Pool{ + New: func() interface{} { + return bytes.NewBuffer(make([]byte, defaultBufferSize)) + }, +} +// getBuffer 获取缓冲区 +func getBuffer() (*bytes.Buffer, func()) { + buf := bufferPool.Get().(*bytes.Buffer) + buf.Reset() return buf, func() { if buf != nil { - pool.Put(buf) + bufferPool.Put(buf) } } } @@ -238,12 +202,12 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler { transport := &http.Transport{ DialContext: dialer.DialContext, - MaxIdleConns: 300, - MaxIdleConnsPerHost: 50, + MaxIdleConns: 1000, // 增加最大空闲连接数 + MaxIdleConnsPerHost: 100, // 增加每个主机的最大空闲连接数 IdleConnTimeout: idleConnTimeout, TLSHandshakeTimeout: tlsHandshakeTimeout, ExpectContinueTimeout: 1 * time.Second, - MaxConnsPerHost: 100, + MaxConnsPerHost: 200, // 增加每个主机的最大连接数 DisableKeepAlives: false, DisableCompression: false, 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) { - buf := byteSlicePool.Get().(*[]byte) - defer byteSlicePool.Put(buf) + buf := bufferPool.Get().(*bytes.Buffer) + defer bufferPool.Put(buf) + buf.Reset() - written, err := io.CopyBuffer(dst, src, *buf) - if err == nil && flusher != nil { + var written int64 + 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() + } + } + + if er == io.EOF { + break + } + } + + // 最后一次刷新 + if flusher != nil { flusher.Flush() } - return written, err + return written, nil } 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 } - // 添加请求追踪标识 - requestID := utils.GenerateRequestID() - proxyReq.Header.Set("X-Request-ID", requestID) - w.Header().Set("X-Request-ID", requestID) - // 复制并处理请求头 copyHeader(proxyReq.Header, r.Header) @@ -492,13 +482,15 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { contentLength := resp.ContentLength if contentLength > 0 && contentLength < 1<<20 { // 1MB 以下的小响应 // 获取合适大小的缓冲区 - buf, putBuffer := getBuffer(contentLength) + buf, putBuffer := getBuffer() defer putBuffer() // 使用缓冲区读取响应 _, err := io.Copy(buf, resp.Body) if err != nil { - h.errorHandler(w, r, fmt.Errorf("error reading response: %v", err)) + if !isConnectionClosed(err) { + h.errorHandler(w, r, fmt.Errorf("error reading response: %v", err)) + } 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) } } diff --git a/internal/metrics/collector.go b/internal/metrics/collector.go index 4f6f953..4d86bb0 100644 --- a/internal/metrics/collector.go +++ b/internal/metrics/collector.go @@ -3,6 +3,7 @@ package metrics import ( "fmt" "log" + "math" "net/http" "proxy-go/internal/config" "proxy-go/internal/models" @@ -16,16 +17,27 @@ import ( // Collector 指标收集器 type Collector struct { - startTime time.Time - activeRequests int64 - totalRequests int64 - totalErrors int64 - totalBytes int64 - latencySum int64 - pathStats sync.Map - statusCodeStats sync.Map - recentRequests *models.RequestQueue - config *config.Config + startTime time.Time + activeRequests int64 + totalRequests int64 + totalErrors int64 + totalBytes int64 + latencySum int64 + maxLatency int64 // 最大响应时间 + minLatency int64 // 最小响应时间 + clientErrors int64 // 4xx错误 + serverErrors int64 // 5xx错误 + pathStats sync.Map + statusCodeStats sync.Map + latencyBuckets sync.Map // 响应时间分布 + bandwidthStats sync.Map // 带宽统计 + errorTypes sync.Map // 错误类型统计 + recentRequests []models.RequestLog + recentRequestsMutex sync.RWMutex + pathStatsMutex sync.RWMutex + config *config.Config + lastMinute time.Time // 用于计算每分钟带宽 + minuteBytes int64 // 当前分钟的字节数 } var ( @@ -38,9 +50,18 @@ func InitCollector(cfg *config.Config) error { once.Do(func() { instance = &Collector{ startTime: time.Now(), - recentRequests: models.NewRequestQueue(1000), + lastMinute: time.Now(), + recentRequests: make([]models.RequestLog, 0, 1000), 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 } @@ -62,52 +83,85 @@ func (c *Collector) EndRequest() { // RecordRequest 记录请求 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.totalBytes, bytes) 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" } - - // 更新状态码统计 - statusKey := fmt.Sprintf("%d", status) - if value, ok := c.statusCodeStats.Load(statusKey); ok { - atomic.AddInt64(value.(*int64), 1) + if counter, ok := c.latencyBuckets.Load(bucketKey); ok { + atomic.AddInt64(counter.(*int64), 1) } else { 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 { - stats := pathStats.(models.PathMetrics) - atomic.AddInt64(&stats.RequestCount, 1) + c.pathStatsMutex.Lock() + if value, ok := c.pathStats.Load(path); ok { + stat := value.(models.PathMetrics) + atomic.AddInt64(&stat.RequestCount, 1) if status >= 400 { - atomic.AddInt64(&stats.ErrorCount, 1) + atomic.AddInt64(&stat.ErrorCount, 1) } - atomic.AddInt64(&stats.TotalLatency, int64(latency)) - atomic.AddInt64(&stats.BytesTransferred, bytes) + atomic.AddInt64(&stat.TotalLatency, int64(latency)) + atomic.AddInt64(&stat.BytesTransferred, bytes) } else { - stats := models.PathMetrics{ + c.pathStats.Store(path, &models.PathMetrics{ Path: path, 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), 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(), Path: path, Status: status, Latency: int64(latency), BytesSent: bytes, ClientIP: clientIP, - }) + }}, c.recentRequests...) + if len(c.recentRequests) > 100 { // 只保留最近100条记录 + c.recentRequests = c.recentRequests[:100] + } + c.recentRequestsMutex.Unlock() } // GetStats 获取统计数据 @@ -117,8 +171,9 @@ func (c *Collector) GetStats() map[string]interface{} { // 计算平均延迟 avgLatency := float64(0) - if c.totalRequests > 0 { - avgLatency = float64(c.latencySum) / float64(c.totalRequests) + totalReqs := atomic.LoadInt64(&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 c.pathStats.Range(func(key, value interface{}) bool { 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) return true }) - // 按请求数排序 + // 按请求数降序排序 sort.Slice(pathMetrics, func(i, j int) bool { return pathMetrics[i].RequestCount > pathMetrics[j].RequestCount }) @@ -146,11 +205,34 @@ func (c *Collector) GetStats() map[string]interface{} { pathMetrics = pathMetrics[:10] } - // 计算每个路径的平均延迟 - for i := range pathMetrics { - if pathMetrics[i].RequestCount > 0 { - avgLatencyMs := float64(pathMetrics[i].TotalLatency) / float64(pathMetrics[i].RequestCount) / float64(time.Millisecond) - pathMetrics[i].AvgLatency = fmt.Sprintf("%.2fms", avgLatencyMs) + // 收集延迟分布 + latencyDistribution := make(map[string]int64) + c.latencyBuckets.Range(func(key, value interface{}) bool { + latencyDistribution[key.(string)] = atomic.LoadInt64(value.(*int64)) + 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)), "status_code_stats": statusCodeStats, "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 验证当前数据的有效性 func (c *Collector) validateLoadedData() error { // 验证基础指标 - if atomic.LoadInt64(&c.totalRequests) < 0 || - atomic.LoadInt64(&c.totalErrors) < 0 || - atomic.LoadInt64(&c.totalBytes) < 0 { + if c.totalRequests < 0 || + c.totalErrors < 0 || + c.totalBytes < 0 { 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") } // 验证状态码统计 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") } @@ -235,7 +329,7 @@ func (c *Collector) GetLastSaveTime() time.Time { // CheckDataConsistency 实现 interfaces.MetricsCollector 接口 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 nil diff --git a/internal/metrics/types.go b/internal/metrics/types.go deleted file mode 100644 index 7742ec2..0000000 --- a/internal/metrics/types.go +++ /dev/null @@ -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 -} diff --git a/web/app/dashboard/page.tsx b/web/app/dashboard/page.tsx index 66272bc..9c72d90 100644 --- a/web/app/dashboard/page.tsx +++ b/web/app/dashboard/page.tsx @@ -4,6 +4,21 @@ import { useEffect, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { useToast } from "@/components/ui/use-toast" import { useRouter } from "next/navigation" +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + PieChart, + Pie, + Cell, + LineChart, + Line, +} from "recharts" interface Metrics { uptime: string @@ -30,8 +45,24 @@ interface Metrics { BytesSent: number ClientIP: string }> + latency_stats: { + min: string + max: string + distribution: Record + } + error_stats: { + client_errors: number + server_errors: number + types: Record + } + bandwidth_history: Record + current_bandwidth: string + total_bytes: number } +// 颜色常量 +const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8'] + export default function DashboardPage() { const [metrics, setMetrics] = useState(null) const [loading, setLoading] = useState(true) @@ -251,8 +282,10 @@ export default function DashboardPage() { - {(metrics.recent_requests || []).map((req, index) => ( - + {(metrics.recent_requests || []) + .slice(0, 20) // 只显示最近20条记录 + .map((req, index) => ( + {formatDate(req.Time)} {req.Path} @@ -274,6 +307,144 @@ export default function DashboardPage() { + + + + 延迟统计 + + +
+
+
最小响应时间
+
{metrics?.latency_stats?.min}
+
+
+
最大响应时间
+
{metrics?.latency_stats?.max}
+
+
+
平均响应时间
+
{metrics?.avg_response_time}
+
+
+
+ + ({ + name, + value, + }))} + margin={{ top: 20, right: 30, left: 20, bottom: 5 }} + > + + + + + + + + +
+
+
+ +
+ + + 错误分布 + + +
+ + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {[0, 1].map((entry, index) => ( + + ))} + + + + + +
+
+
+ + + + 错误类型统计 + + +
+ + ({ + name, + value, + }))} + layout="vertical" + margin={{ top: 5, right: 30, left: 100, bottom: 5 }} + > + + + + + + + +
+
+
+
+ + + + 带宽统计 + + +
+
+
当前带宽
+
{metrics?.current_bandwidth}
+
+
+
总传输数据
+
{formatBytes(metrics?.total_bytes || 0)}
+
+
+
+ + ({ + time, + value: parseFloat(value.split(' ')[0]), + unit: value.split(' ')[1], + }))} + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} + > + + + + [`${value} ${props.payload.unit}`, '带宽']} /> + + + + +
+
+
) } diff --git a/web/components/ui/chart.tsx b/web/components/ui/chart.tsx new file mode 100644 index 0000000..32dc873 --- /dev/null +++ b/web/components/ui/chart.tsx @@ -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 } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + 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 ( + +
+ + + {children} + +
+
+ ) +}) +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 ( +