From 775814eb24ee3fa3ed03b9d0d0c99de1dd31ad78 Mon Sep 17 00:00:00 2001 From: wood chen Date: Sun, 13 Jul 2025 04:31:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BD=93=E5=89=8D=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=AF=B7=E6=B1=82=E6=95=B0=E7=BB=9F=E8=AE=A1=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AF=B7=E6=B1=82=E7=AA=97=E5=8F=A3=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=9B=B4=E6=96=B0=E4=BB=AA=E8=A1=A8=E6=9D=BF?= =?UTF-8?q?=E4=BB=A5=E5=B1=95=E7=A4=BA=E7=BC=93=E5=AD=98=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=92=8C=E6=99=BA=E8=83=BD=E7=BC=93=E5=AD=98=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=B7=A5=E5=85=B7=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BB=A5=E5=A2=9E=E5=BC=BA=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/metrics.go | 67 ++-- internal/metrics/collector.go | 57 ++-- internal/utils/utils.go | 20 ++ web/app/dashboard/cache/page.tsx | 533 +++++++++++++++++++++---------- web/components/ui/tooltip.tsx | 32 ++ web/package-lock.json | 425 ++++++++++++++++++++++++ web/package.json | 1 + 7 files changed, 901 insertions(+), 234 deletions(-) create mode 100644 web/components/ui/tooltip.tsx diff --git a/internal/handler/metrics.go b/internal/handler/metrics.go index b4c2120..bc96b2b 100644 --- a/internal/handler/metrics.go +++ b/internal/handler/metrics.go @@ -25,8 +25,9 @@ type Metrics struct { MemoryUsage string `json:"memory_usage"` // 性能指标 - AverageResponseTime string `json:"avg_response_time"` - RequestsPerSecond float64 `json:"requests_per_second"` + AverageResponseTime string `json:"avg_response_time"` + RequestsPerSecond float64 `json:"requests_per_second"` + CurrentSessionRequests int64 `json:"current_session_requests"` // 传输指标 TotalBytes int64 `json:"total_bytes"` @@ -61,20 +62,21 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) { if stats == nil { stats = map[string]interface{}{ - "uptime": metrics.FormatUptime(uptime), - "active_requests": int64(0), - "total_requests": int64(0), - "total_errors": int64(0), - "error_rate": float64(0), - "num_goroutine": runtime.NumGoroutine(), - "memory_usage": "0 B", - "avg_response_time": "0 ms", - "total_bytes": int64(0), - "bytes_per_second": float64(0), - "requests_per_second": float64(0), - "status_code_stats": make(map[string]int64), - "recent_requests": make([]models.RequestLog, 0), - "top_referers": make([]models.PathMetrics, 0), + "uptime": metrics.FormatUptime(uptime), + "active_requests": int64(0), + "total_requests": int64(0), + "total_errors": int64(0), + "error_rate": float64(0), + "num_goroutine": runtime.NumGoroutine(), + "memory_usage": "0 B", + "avg_response_time": "0 ms", + "total_bytes": int64(0), + "bytes_per_second": float64(0), + "requests_per_second": float64(0), + "current_session_requests": int64(0), + "status_code_stats": make(map[string]int64), + "recent_requests": make([]models.RequestLog, 0), + "top_referers": make([]models.PathMetrics, 0), "latency_stats": map[string]interface{}{ "min": "0ms", "max": "0ms", @@ -108,22 +110,23 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) { statusCodeStats := models.SafeStatusCodeStats(stats["status_code_stats"]) metrics := Metrics{ - Uptime: metrics.FormatUptime(uptime), - ActiveRequests: utils.SafeInt64(stats["active_requests"]), - TotalRequests: totalRequests, - TotalErrors: totalErrors, - ErrorRate: float64(totalErrors) / float64(utils.Max(totalRequests, 1)), - NumGoroutine: utils.SafeInt(stats["num_goroutine"]), - MemoryUsage: utils.SafeString(stats["memory_usage"], "0 B"), - AverageResponseTime: utils.SafeString(stats["avg_response_time"], "0 ms"), - TotalBytes: totalBytes, - BytesPerSecond: float64(totalBytes) / utils.MaxFloat64(uptimeSeconds, 1), - RequestsPerSecond: float64(totalRequests) / utils.MaxFloat64(uptimeSeconds, 1), - StatusCodeStats: statusCodeStats, - RecentRequests: models.SafeRequestLogs(stats["recent_requests"]), - TopReferers: models.SafePathMetrics(stats["top_referers"]), - BandwidthHistory: bandwidthHistory, - CurrentBandwidth: utils.SafeString(stats["current_bandwidth"], "0 B/s"), + Uptime: metrics.FormatUptime(uptime), + ActiveRequests: utils.SafeInt64(stats["active_requests"]), + TotalRequests: totalRequests, + TotalErrors: totalErrors, + ErrorRate: float64(totalErrors) / float64(utils.Max(totalRequests, 1)), + NumGoroutine: utils.SafeInt(stats["num_goroutine"]), + MemoryUsage: utils.SafeString(stats["memory_usage"], "0 B"), + AverageResponseTime: utils.SafeString(stats["avg_response_time"], "0 ms"), + TotalBytes: totalBytes, + BytesPerSecond: float64(totalBytes) / utils.MaxFloat64(uptimeSeconds, 1), + RequestsPerSecond: utils.SafeFloat64(stats["requests_per_second"]), + CurrentSessionRequests: utils.SafeInt64(stats["current_session_requests"]), + StatusCodeStats: statusCodeStats, + RecentRequests: models.SafeRequestLogs(stats["recent_requests"]), + TopReferers: models.SafePathMetrics(stats["top_referers"]), + BandwidthHistory: bandwidthHistory, + CurrentBandwidth: utils.SafeString(stats["current_bandwidth"], "0 B/s"), } // 填充延迟统计数据 diff --git a/internal/metrics/collector.go b/internal/metrics/collector.go index 96cba7c..d7ed426 100644 --- a/internal/metrics/collector.go +++ b/internal/metrics/collector.go @@ -583,44 +583,39 @@ func (c *Collector) updateRequestsWindow(count int64) { now := time.Now() - // 计算当前应该在哪个桶 + // 如果是第一次调用,初始化时间 + if c.requestsWindow.lastUpdate.IsZero() { + c.requestsWindow.lastUpdate = now + } + + // 计算当前时间桶的索引 timeSinceLastUpdate := now.Sub(c.requestsWindow.lastUpdate) // 如果时间跨度超过桶大小,需要移动到新桶 if timeSinceLastUpdate >= c.requestsWindow.bucketSize { - // 保存当前桶的数据 - if len(c.requestsWindow.buckets) > 0 { - // 向前移动桶(最新的在前面) - bucketsToMove := int(timeSinceLastUpdate / c.requestsWindow.bucketSize) - if bucketsToMove >= len(c.requestsWindow.buckets) { - // 如果移动的桶数超过总桶数,清空所有桶 - for i := range c.requestsWindow.buckets { - c.requestsWindow.buckets[i] = 0 - } - } else { - // 移动桶数据 - copy(c.requestsWindow.buckets[bucketsToMove:], c.requestsWindow.buckets[:len(c.requestsWindow.buckets)-bucketsToMove]) - // 清空前面的桶 - for i := 0; i < bucketsToMove; i++ { - c.requestsWindow.buckets[i] = 0 - } + bucketsToMove := int(timeSinceLastUpdate / c.requestsWindow.bucketSize) + + if bucketsToMove >= len(c.requestsWindow.buckets) { + // 如果移动的桶数超过总桶数,清空所有桶 + for i := range c.requestsWindow.buckets { + c.requestsWindow.buckets[i] = 0 + } + } else { + // 向右移动桶数据(新数据在索引0) + copy(c.requestsWindow.buckets[bucketsToMove:], c.requestsWindow.buckets[:len(c.requestsWindow.buckets)-bucketsToMove]) + // 清空前面的桶 + for i := 0; i < bucketsToMove; i++ { + c.requestsWindow.buckets[i] = 0 } } - // 更新当前桶为新请求数 - c.requestsWindow.current = count - c.requestsWindow.lastUpdate = now + // 更新时间为当前桶的开始时间 + c.requestsWindow.lastUpdate = now.Truncate(c.requestsWindow.bucketSize) + } - // 将当前请求数加到第一个桶 - if len(c.requestsWindow.buckets) > 0 { - c.requestsWindow.buckets[0] = count - } - } else { - // 在同一个桶内,累加请求数 - c.requestsWindow.current += count - if len(c.requestsWindow.buckets) > 0 { - c.requestsWindow.buckets[0] += count - } + // 将请求数加到第一个桶(当前时间桶) + if len(c.requestsWindow.buckets) > 0 { + c.requestsWindow.buckets[0] += count } } @@ -640,7 +635,7 @@ func (c *Collector) getRecentRequestsPerSecond() float64 { actualWindow := c.requestsWindow.window // 如果程序运行时间不足5分钟,使用实际运行时间 - if runTime := now.Sub(c.requestsWindow.lastUpdate); runTime < c.requestsWindow.window { + if runTime := now.Sub(c.startTime); runTime < c.requestsWindow.window { actualWindow = runTime } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 10bfe8a..0852ce3 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -459,6 +459,26 @@ func SafeString(v interface{}, defaultValue string) string { return defaultValue } +func SafeFloat64(v interface{}) float64 { + if v == nil { + return 0 + } + switch val := v.(type) { + case float64: + return val + case float32: + return float64(val) + case int64: + return float64(val) + case int: + return float64(val) + case int32: + return float64(val) + default: + return 0 + } +} + // Max 返回两个 int64 中的较大值 func Max(a, b int64) int64 { if a > b { diff --git a/web/app/dashboard/cache/page.tsx b/web/app/dashboard/cache/page.tsx index e5fd057..0451786 100644 --- a/web/app/dashboard/cache/page.tsx +++ b/web/app/dashboard/cache/page.tsx @@ -7,7 +7,24 @@ import { useToast } from "@/components/ui/use-toast" import { Switch } from "@/components/ui/switch" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { useRouter } from "next/navigation" +import { + HardDrive, + Database, + TrendingUp, + TrendingDown, + Activity, + Image as ImageIcon, + FileText, + RefreshCw, + Trash2, + Settings, + Info, + Zap, + Target, + RotateCcw +} from "lucide-react" interface CacheStats { total_items: number @@ -259,15 +276,19 @@ export default function CachePage() { const config = configs[type] return ( -
-

缓存配置

+
+
+ +

缓存配置

+
- + { const newConfigs = { ...configs } newConfigs[type].max_age = parseInt(e.target.value) @@ -277,11 +298,12 @@ export default function CachePage() { />
- + { const newConfigs = { ...configs } newConfigs[type].cleanup_tick = parseInt(e.target.value) @@ -291,11 +313,12 @@ export default function CachePage() { />
- + { const newConfigs = { ...configs } newConfigs[type].max_cache_size = parseInt(e.target.value) @@ -313,6 +336,7 @@ export default function CachePage() { return (
+
加载中...
正在获取缓存统计信息
@@ -321,185 +345,352 @@ export default function CachePage() { } return ( -
-
- -
- - {/* 智能缓存汇总 */} - - - 智能缓存汇总 - - -
-
-
- {(stats?.proxy.regular_cache_hit ?? 0) + (stats?.mirror.regular_cache_hit ?? 0)} -
-
常规缓存命中
-
-
-
- {(stats?.proxy.image_cache_hit ?? 0) + (stats?.mirror.image_cache_hit ?? 0)} -
-
图片精确命中
-
-
-
- {(stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0)} -
-
格式回退命中
-
-
-
- {(() => { - const totalImageRequests = (stats?.proxy.image_cache_hit ?? 0) + (stats?.mirror.image_cache_hit ?? 0) + (stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0) - const fallbackHits = (stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0) - return totalImageRequests > 0 ? ((fallbackHits / totalImageRequests) * 100).toFixed(1) : '0.0' - })()}% -
-
格式回退率
-
+ +
+
+
+ +

缓存管理

- - + +
-
- {/* 代理缓存 */} - - - 代理缓存 -
- handleToggleCache("proxy", checked)} - /> - -
+ {/* 智能缓存汇总 */} + + + + + 智能缓存汇总 + -
-
-
缓存项数量
-
{stats?.proxy.total_items ?? 0}
-
-
-
总大小
-
{formatBytes(stats?.proxy.total_size ?? 0)}
-
-
-
命中次数
-
{stats?.proxy.hit_count ?? 0}
-
-
-
未命中次数
-
{stats?.proxy.miss_count ?? 0}
-
-
-
命中率
-
{(stats?.proxy.hit_rate ?? 0).toFixed(2)}%
-
-
-
节省带宽
-
{formatBytes(stats?.proxy.bytes_saved ?? 0)}
-
-
-
智能缓存统计
-
-
-
{stats?.proxy.regular_cache_hit ?? 0}
-
常规命中
+
+ + +
+
+ + +
+
+ {(stats?.proxy.regular_cache_hit ?? 0) + (stats?.mirror.regular_cache_hit ?? 0)} +
+
常规缓存命中
-
-
{stats?.proxy.image_cache_hit ?? 0}
-
图片命中
+ + +

所有常规文件的精确缓存命中总数

+
+ + + + +
+
+
+
+ {(stats?.proxy.image_cache_hit ?? 0) + (stats?.mirror.image_cache_hit ?? 0)} +
+
图片精确命中
-
-
{stats?.proxy.format_fallback_hit ?? 0}
-
格式回退
+ + +

所有图片文件的精确格式缓存命中总数

+
+ + + + +
+
+ + +
+
+ {(stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0)} +
+
格式回退命中
-
-
-
- {renderCacheConfig("proxy")} + + +

图片格式回退命中总数,提高了缓存效率

+
+ + + + +
+
+ + +
+
+ {(() => { + const totalImageRequests = (stats?.proxy.image_cache_hit ?? 0) + (stats?.mirror.image_cache_hit ?? 0) + (stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0) + const fallbackHits = (stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0) + return totalImageRequests > 0 ? ((fallbackHits / totalImageRequests) * 100).toFixed(1) : '0.0' + })()}% +
+
格式回退率
+
+
+ +

格式回退在所有图片请求中的占比,显示智能缓存的效果

+
+
+
- {/* 镜像缓存 */} - - - 镜像缓存 -
- handleToggleCache("mirror", checked)} - /> - -
-
- -
-
-
缓存项数量
-
{stats?.mirror.total_items ?? 0}
+
+ {/* 代理缓存 */} + + + + + 代理缓存 + +
+ handleToggleCache("proxy", checked)} + /> +
-
-
总大小
-
{formatBytes(stats?.mirror.total_size ?? 0)}
-
-
-
命中次数
-
{stats?.mirror.hit_count ?? 0}
-
-
-
未命中次数
-
{stats?.mirror.miss_count ?? 0}
-
-
-
命中率
-
{(stats?.mirror.hit_rate ?? 0).toFixed(2)}%
-
-
-
节省带宽
-
{formatBytes(stats?.mirror.bytes_saved ?? 0)}
-
-
-
智能缓存统计
-
-
-
{stats?.mirror.regular_cache_hit ?? 0}
-
常规命中
-
-
-
{stats?.mirror.image_cache_hit ?? 0}
-
图片命中
-
-
-
{stats?.mirror.format_fallback_hit ?? 0}
-
格式回退
-
+ + +
+
+
+ + 缓存项数量 +
+
{stats?.proxy.total_items ?? 0}
+
+
+
+ + 总大小 +
+
{formatBytes(stats?.proxy.total_size ?? 0)}
+
+
+
+ + 命中次数 +
+
{stats?.proxy.hit_count ?? 0}
+
+
+
+ + 未命中次数 +
+
{stats?.proxy.miss_count ?? 0}
+
+
+
+ + 命中率 +
+
{(stats?.proxy.hit_rate ?? 0).toFixed(2)}%
+
+
+
+ + 节省带宽 +
+
{formatBytes(stats?.proxy.bytes_saved ?? 0)}
+
+
+ +
+
+ +
智能缓存统计
+
+
+ + +
+ +
{stats?.proxy.regular_cache_hit ?? 0}
+
常规命中
+
+
+ +

常规文件的精确缓存命中

+
+
+ + + +
+
+
+ +

图片文件的精确格式缓存命中

+
+
+ + + +
+ +
{stats?.proxy.format_fallback_hit ?? 0}
+
格式回退
+
+
+ +

图片格式回退命中(如请求WebP但提供JPEG)

+
+
-
- {renderCacheConfig("mirror")} -
-
+ {renderCacheConfig("proxy")} + + + + {/* 镜像缓存 */} + + + + + 镜像缓存 + +
+ handleToggleCache("mirror", checked)} + /> + +
+
+ +
+
+
+ + 缓存项数量 +
+
{stats?.mirror.total_items ?? 0}
+
+
+
+ + 总大小 +
+
{formatBytes(stats?.mirror.total_size ?? 0)}
+
+
+
+ + 命中次数 +
+
{stats?.mirror.hit_count ?? 0}
+
+
+
+ + 未命中次数 +
+
{stats?.mirror.miss_count ?? 0}
+
+
+
+ + 命中率 +
+
{(stats?.mirror.hit_rate ?? 0).toFixed(2)}%
+
+
+
+ + 节省带宽 +
+
{formatBytes(stats?.mirror.bytes_saved ?? 0)}
+
+
+ +
+
+ +
智能缓存统计
+
+
+ + +
+ +
{stats?.mirror.regular_cache_hit ?? 0}
+
常规命中
+
+
+ +

常规文件的精确缓存命中

+
+
+ + + +
+
+
+ +

图片文件的精确格式缓存命中

+
+
+ + + +
+ +
{stats?.mirror.format_fallback_hit ?? 0}
+
格式回退
+
+
+ +

图片格式回退命中(如请求WebP但提供JPEG)

+
+
+
+
+ {renderCacheConfig("mirror")} +
+
+
-
+ ) } \ No newline at end of file diff --git a/web/components/ui/tooltip.tsx b/web/components/ui/tooltip.tsx new file mode 100644 index 0000000..28e1918 --- /dev/null +++ b/web/components/ui/tooltip.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/web/package-lock.json b/web/package-lock.json index e29432c..0ce9a40 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", @@ -1542,6 +1543,397 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -1575,6 +1967,39 @@ } } }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index fd23c8b..cc744c3 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0",