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.image_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.image_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",