mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 00:21:56 +08:00
Compare commits
4 Commits
6fd69ba870
...
8e484f29e9
Author | SHA1 | Date | |
---|---|---|---|
8e484f29e9 | |||
775814eb24 | |||
19c25b8aca | |||
1e77085e10 |
337
internal/cache/manager.go
vendored
337
internal/cache/manager.go
vendored
@ -228,13 +228,16 @@ type CacheItem struct {
|
|||||||
|
|
||||||
// CacheStats 缓存统计信息
|
// CacheStats 缓存统计信息
|
||||||
type CacheStats struct {
|
type CacheStats struct {
|
||||||
TotalItems int `json:"total_items"` // 缓存项数量
|
TotalItems int `json:"total_items"` // 缓存项数量
|
||||||
TotalSize int64 `json:"total_size"` // 总大小
|
TotalSize int64 `json:"total_size"` // 总大小
|
||||||
HitCount int64 `json:"hit_count"` // 命中次数
|
HitCount int64 `json:"hit_count"` // 命中次数
|
||||||
MissCount int64 `json:"miss_count"` // 未命中次数
|
MissCount int64 `json:"miss_count"` // 未命中次数
|
||||||
HitRate float64 `json:"hit_rate"` // 命中率
|
HitRate float64 `json:"hit_rate"` // 命中率
|
||||||
BytesSaved int64 `json:"bytes_saved"` // 节省的带宽
|
BytesSaved int64 `json:"bytes_saved"` // 节省的带宽
|
||||||
Enabled bool `json:"enabled"` // 缓存开关状态
|
Enabled bool `json:"enabled"` // 缓存开关状态
|
||||||
|
FormatFallbackHit int64 `json:"format_fallback_hit"` // 格式回退命中次数
|
||||||
|
ImageCacheHit int64 `json:"image_cache_hit"` // 图片缓存命中次数
|
||||||
|
RegularCacheHit int64 `json:"regular_cache_hit"` // 常规缓存命中次数
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheManager 缓存管理器
|
// CacheManager 缓存管理器
|
||||||
@ -252,11 +255,13 @@ type CacheManager struct {
|
|||||||
cleanupTimer *time.Ticker // 添加清理定时器
|
cleanupTimer *time.Ticker // 添加清理定时器
|
||||||
stopCleanup chan struct{} // 添加停止信号通道
|
stopCleanup chan struct{} // 添加停止信号通道
|
||||||
|
|
||||||
|
// 新增:格式回退统计
|
||||||
|
formatFallbackHit atomic.Int64 // 格式回退命中次数
|
||||||
|
imageCacheHit atomic.Int64 // 图片缓存命中次数
|
||||||
|
regularCacheHit atomic.Int64 // 常规缓存命中次数
|
||||||
|
|
||||||
// ExtensionMatcher缓存
|
// ExtensionMatcher缓存
|
||||||
extensionMatcherCache *ExtensionMatcherCache
|
extensionMatcherCache *ExtensionMatcherCache
|
||||||
|
|
||||||
// 缓存预热
|
|
||||||
prewarming atomic.Bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCacheManager 创建新的缓存管理器
|
// NewCacheManager 创建新的缓存管理器
|
||||||
@ -292,9 +297,6 @@ func NewCacheManager(cacheDir string) (*CacheManager, error) {
|
|||||||
// 启动清理协程
|
// 启动清理协程
|
||||||
cm.startCleanup()
|
cm.startCleanup()
|
||||||
|
|
||||||
// 启动缓存预热
|
|
||||||
go cm.prewarmCache()
|
|
||||||
|
|
||||||
return cm, nil
|
return cm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,10 +313,79 @@ func (cm *CacheManager) GenerateCacheKey(r *http.Request) CacheKey {
|
|||||||
}
|
}
|
||||||
sort.Strings(varyHeaders)
|
sort.Strings(varyHeaders)
|
||||||
|
|
||||||
|
url := r.URL.String()
|
||||||
|
acceptHeaders := r.Header.Get("Accept")
|
||||||
|
userAgent := r.Header.Get("User-Agent")
|
||||||
|
|
||||||
|
// 🎯 针对图片请求进行智能缓存键优化
|
||||||
|
if utils.IsImageRequest(r.URL.Path) {
|
||||||
|
// 解析Accept头中的图片格式偏好
|
||||||
|
imageFormat := cm.parseImageFormatPreference(acceptHeaders)
|
||||||
|
|
||||||
|
// 为图片请求生成格式感知的缓存键
|
||||||
|
return CacheKey{
|
||||||
|
URL: url,
|
||||||
|
AcceptHeaders: imageFormat, // 使用标准化的图片格式
|
||||||
|
UserAgent: cm.normalizeUserAgent(userAgent), // 标准化UserAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return CacheKey{
|
return CacheKey{
|
||||||
URL: r.URL.String(),
|
URL: url,
|
||||||
AcceptHeaders: r.Header.Get("Accept"),
|
AcceptHeaders: acceptHeaders,
|
||||||
UserAgent: r.Header.Get("User-Agent"),
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageFormatPreference 解析图片格式偏好,返回标准化的格式标识
|
||||||
|
func (cm *CacheManager) parseImageFormatPreference(accept string) string {
|
||||||
|
if accept == "" {
|
||||||
|
return "image/jpeg" // 默认格式
|
||||||
|
}
|
||||||
|
|
||||||
|
accept = strings.ToLower(accept)
|
||||||
|
|
||||||
|
// 按优先级检查现代图片格式
|
||||||
|
switch {
|
||||||
|
case strings.Contains(accept, "image/avif"):
|
||||||
|
return "image/avif"
|
||||||
|
case strings.Contains(accept, "image/webp"):
|
||||||
|
return "image/webp"
|
||||||
|
case strings.Contains(accept, "image/jpeg") || strings.Contains(accept, "image/jpg"):
|
||||||
|
return "image/jpeg"
|
||||||
|
case strings.Contains(accept, "image/png"):
|
||||||
|
return "image/png"
|
||||||
|
case strings.Contains(accept, "image/gif"):
|
||||||
|
return "image/gif"
|
||||||
|
case strings.Contains(accept, "image/*"):
|
||||||
|
return "image/auto" // 自动格式
|
||||||
|
default:
|
||||||
|
return "image/jpeg" // 默认格式
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeUserAgent 标准化UserAgent,减少缓存键的变化
|
||||||
|
func (cm *CacheManager) normalizeUserAgent(ua string) string {
|
||||||
|
if ua == "" {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
ua = strings.ToLower(ua)
|
||||||
|
|
||||||
|
// 根据主要浏览器类型进行分类
|
||||||
|
switch {
|
||||||
|
case strings.Contains(ua, "chrome") && !strings.Contains(ua, "edge"):
|
||||||
|
return "chrome"
|
||||||
|
case strings.Contains(ua, "firefox"):
|
||||||
|
return "firefox"
|
||||||
|
case strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome"):
|
||||||
|
return "safari"
|
||||||
|
case strings.Contains(ua, "edge"):
|
||||||
|
return "edge"
|
||||||
|
case strings.Contains(ua, "bot") || strings.Contains(ua, "crawler"):
|
||||||
|
return "bot"
|
||||||
|
default:
|
||||||
|
return "other"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,6 +395,100 @@ func (cm *CacheManager) Get(key CacheKey, r *http.Request) (*CacheItem, bool, bo
|
|||||||
return nil, false, false
|
return nil, false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🎯 针对图片请求实现智能格式回退
|
||||||
|
if utils.IsImageRequest(r.URL.Path) {
|
||||||
|
return cm.getImageWithFallback(key, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cm.getRegularItem(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getImageWithFallback 获取图片缓存项,支持格式回退
|
||||||
|
func (cm *CacheManager) getImageWithFallback(key CacheKey, r *http.Request) (*CacheItem, bool, bool) {
|
||||||
|
// 首先尝试精确匹配
|
||||||
|
if item, found, notModified := cm.getRegularItem(key); found {
|
||||||
|
cm.imageCacheHit.Add(1)
|
||||||
|
return item, found, notModified
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果精确匹配失败,尝试格式回退
|
||||||
|
if item, found, notModified := cm.tryFormatFallback(key, r); found {
|
||||||
|
cm.formatFallbackHit.Add(1)
|
||||||
|
return item, found, notModified
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryFormatFallback 尝试格式回退
|
||||||
|
func (cm *CacheManager) tryFormatFallback(originalKey CacheKey, r *http.Request) (*CacheItem, bool, bool) {
|
||||||
|
requestedFormat := originalKey.AcceptHeaders
|
||||||
|
|
||||||
|
// 定义格式回退顺序
|
||||||
|
fallbackFormats := cm.getFormatFallbackOrder(requestedFormat)
|
||||||
|
|
||||||
|
for _, format := range fallbackFormats {
|
||||||
|
fallbackKey := CacheKey{
|
||||||
|
URL: originalKey.URL,
|
||||||
|
AcceptHeaders: format,
|
||||||
|
UserAgent: originalKey.UserAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
if item, found, notModified := cm.getRegularItem(fallbackKey); found {
|
||||||
|
// 找到了兼容格式,检查是否真的兼容
|
||||||
|
if cm.isFormatCompatible(requestedFormat, format, item.ContentType) {
|
||||||
|
log.Printf("[Cache] 格式回退: %s -> %s (%s)", requestedFormat, format, originalKey.URL)
|
||||||
|
return item, found, notModified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFormatFallbackOrder 获取格式回退顺序
|
||||||
|
func (cm *CacheManager) getFormatFallbackOrder(requestedFormat string) []string {
|
||||||
|
switch requestedFormat {
|
||||||
|
case "image/avif":
|
||||||
|
return []string{"image/webp", "image/jpeg", "image/png"}
|
||||||
|
case "image/webp":
|
||||||
|
return []string{"image/jpeg", "image/png", "image/avif"}
|
||||||
|
case "image/jpeg":
|
||||||
|
return []string{"image/webp", "image/png", "image/avif"}
|
||||||
|
case "image/png":
|
||||||
|
return []string{"image/webp", "image/jpeg", "image/avif"}
|
||||||
|
case "image/auto":
|
||||||
|
return []string{"image/webp", "image/avif", "image/jpeg", "image/png"}
|
||||||
|
default:
|
||||||
|
return []string{"image/jpeg", "image/webp", "image/png"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFormatCompatible 检查格式是否兼容
|
||||||
|
func (cm *CacheManager) isFormatCompatible(requestedFormat, cachedFormat, actualContentType string) bool {
|
||||||
|
// 如果是自动格式,接受任何现代格式
|
||||||
|
if requestedFormat == "image/auto" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 现代浏览器通常可以处理多种格式
|
||||||
|
modernFormats := map[string]bool{
|
||||||
|
"image/webp": true,
|
||||||
|
"image/avif": true,
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查实际内容类型是否为现代格式
|
||||||
|
if actualContentType != "" {
|
||||||
|
return modernFormats[strings.ToLower(actualContentType)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return modernFormats[cachedFormat]
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRegularItem 获取常规缓存项(原有逻辑)
|
||||||
|
func (cm *CacheManager) getRegularItem(key CacheKey) (*CacheItem, bool, bool) {
|
||||||
// 检查LRU缓存
|
// 检查LRU缓存
|
||||||
if item, found := cm.lruCache.Get(key); found {
|
if item, found := cm.lruCache.Get(key); found {
|
||||||
// 检查LRU缓存项是否过期
|
// 检查LRU缓存项是否过期
|
||||||
@ -336,6 +501,7 @@ func (cm *CacheManager) Get(key CacheKey, r *http.Request) (*CacheItem, bool, bo
|
|||||||
item.LastAccess = time.Now()
|
item.LastAccess = time.Now()
|
||||||
atomic.AddInt64(&item.AccessCount, 1)
|
atomic.AddInt64(&item.AccessCount, 1)
|
||||||
cm.hitCount.Add(1)
|
cm.hitCount.Add(1)
|
||||||
|
cm.regularCacheHit.Add(1)
|
||||||
return item, true, false
|
return item, true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -367,6 +533,7 @@ func (cm *CacheManager) Get(key CacheKey, r *http.Request) (*CacheItem, bool, bo
|
|||||||
item.LastAccess = time.Now()
|
item.LastAccess = time.Now()
|
||||||
atomic.AddInt64(&item.AccessCount, 1)
|
atomic.AddInt64(&item.AccessCount, 1)
|
||||||
cm.hitCount.Add(1)
|
cm.hitCount.Add(1)
|
||||||
|
cm.regularCacheHit.Add(1)
|
||||||
cm.bytesSaved.Add(item.Size)
|
cm.bytesSaved.Add(item.Size)
|
||||||
|
|
||||||
// 将缓存项添加到LRU缓存
|
// 将缓存项添加到LRU缓存
|
||||||
@ -531,13 +698,16 @@ func (cm *CacheManager) GetStats() CacheStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return CacheStats{
|
return CacheStats{
|
||||||
TotalItems: totalItems,
|
TotalItems: totalItems,
|
||||||
TotalSize: totalSize,
|
TotalSize: totalSize,
|
||||||
HitCount: hitCount,
|
HitCount: hitCount,
|
||||||
MissCount: missCount,
|
MissCount: missCount,
|
||||||
HitRate: hitRate,
|
HitRate: hitRate,
|
||||||
BytesSaved: cm.bytesSaved.Load(),
|
BytesSaved: cm.bytesSaved.Load(),
|
||||||
Enabled: cm.enabled.Load(),
|
Enabled: cm.enabled.Load(),
|
||||||
|
FormatFallbackHit: cm.formatFallbackHit.Load(),
|
||||||
|
ImageCacheHit: cm.imageCacheHit.Load(),
|
||||||
|
RegularCacheHit: cm.regularCacheHit.Load(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -580,6 +750,9 @@ func (cm *CacheManager) ClearCache() error {
|
|||||||
cm.hitCount.Store(0)
|
cm.hitCount.Store(0)
|
||||||
cm.missCount.Store(0)
|
cm.missCount.Store(0)
|
||||||
cm.bytesSaved.Store(0)
|
cm.bytesSaved.Store(0)
|
||||||
|
cm.formatFallbackHit.Store(0)
|
||||||
|
cm.imageCacheHit.Store(0)
|
||||||
|
cm.regularCacheHit.Store(0)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -650,15 +823,41 @@ func (cm *CacheManager) Commit(key CacheKey, tempPath string, resp *http.Respons
|
|||||||
return fmt.Errorf("cache is disabled")
|
return fmt.Errorf("cache is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成最终的缓存文件名
|
// 读取临时文件内容以计算哈希
|
||||||
h := sha256.New()
|
tempData, err := os.ReadFile(tempPath)
|
||||||
h.Write([]byte(key.String()))
|
if err != nil {
|
||||||
hashStr := hex.EncodeToString(h.Sum(nil))
|
os.Remove(tempPath)
|
||||||
ext := filepath.Ext(key.URL)
|
return fmt.Errorf("failed to read temp file: %v", err)
|
||||||
if ext == "" {
|
|
||||||
ext = ".bin"
|
|
||||||
}
|
}
|
||||||
filePath := filepath.Join(cm.cacheDir, hashStr+ext)
|
|
||||||
|
// 计算内容哈希,与Put方法保持一致
|
||||||
|
contentHash := sha256.Sum256(tempData)
|
||||||
|
hashStr := hex.EncodeToString(contentHash[:])
|
||||||
|
|
||||||
|
// 检查是否存在相同哈希的缓存项
|
||||||
|
var existingItem *CacheItem
|
||||||
|
cm.items.Range(func(k, v interface{}) bool {
|
||||||
|
if item := v.(*CacheItem); item.Hash == hashStr {
|
||||||
|
if _, err := os.Stat(item.FilePath); err == nil {
|
||||||
|
existingItem = item
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cm.items.Delete(k)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if existingItem != nil {
|
||||||
|
// 删除临时文件,使用现有缓存
|
||||||
|
os.Remove(tempPath)
|
||||||
|
cm.items.Store(key, existingItem)
|
||||||
|
log.Printf("[Cache] HIT %s %s (%s) from %s", resp.Request.Method, key.URL, formatBytes(existingItem.Size), utils.GetRequestSource(resp.Request))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成最终的缓存文件名(使用内容哈希)
|
||||||
|
fileName := hashStr
|
||||||
|
filePath := filepath.Join(cm.cacheDir, fileName)
|
||||||
|
|
||||||
// 重命名临时文件
|
// 重命名临时文件
|
||||||
if err := os.Rename(tempPath, filePath); err != nil {
|
if err := os.Rename(tempPath, filePath); err != nil {
|
||||||
@ -848,79 +1047,3 @@ func (cm *CacheManager) Stop() {
|
|||||||
cm.extensionMatcherCache.Stop()
|
cm.extensionMatcherCache.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// prewarmCache 启动缓存预热
|
|
||||||
func (cm *CacheManager) prewarmCache() {
|
|
||||||
// 模拟一些请求来预热缓存
|
|
||||||
// 实际应用中,这里会从数据库或外部服务加载热点数据
|
|
||||||
// 例如,从数据库加载最近访问频率高的URL
|
|
||||||
// 或者从外部API获取热门资源
|
|
||||||
|
|
||||||
// 示例:加载最近访问的URL
|
|
||||||
// 假设我们有一个数据库或文件,记录最近访问的URL和它们的哈希
|
|
||||||
// 这里我们简单地加载一些示例URL
|
|
||||||
exampleUrls := []string{
|
|
||||||
"https://example.com/api/data",
|
|
||||||
"https://api.github.com/repos/golang/go/releases",
|
|
||||||
"https://api.openai.com/v1/models",
|
|
||||||
"https://api.openai.com/v1/chat/completions",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, url := range exampleUrls {
|
|
||||||
// 生成一个随机的Accept Headers和UserAgent
|
|
||||||
acceptHeaders := "application/json"
|
|
||||||
userAgent := "Mozilla/5.0 (compatible; ProxyGo/1.0)"
|
|
||||||
|
|
||||||
// 模拟一个HTTP请求
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[Cache] ERR Failed to create request for prewarming: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", acceptHeaders)
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
|
|
||||||
// 生成缓存键
|
|
||||||
cacheKey := cm.GenerateCacheKey(req)
|
|
||||||
|
|
||||||
// 尝试从LRU缓存获取
|
|
||||||
if _, found := cm.lruCache.Get(cacheKey); found {
|
|
||||||
log.Printf("[Cache] WARN %s (prewarmed)", cacheKey.URL)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试从文件缓存获取
|
|
||||||
if _, ok := cm.items.Load(cacheKey); ok {
|
|
||||||
log.Printf("[Cache] WARN %s (prewarmed)", cacheKey.URL)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟一个HTTP响应
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Request: req,
|
|
||||||
}
|
|
||||||
resp.Header.Set("Content-Type", "application/json")
|
|
||||||
resp.Header.Set("Content-Encoding", "gzip")
|
|
||||||
resp.Header.Set("X-Cache", "HIT") // 模拟缓存命中
|
|
||||||
|
|
||||||
// 模拟一个HTTP请求体
|
|
||||||
body := []byte(`{"message": "Hello from prewarmed cache"}`)
|
|
||||||
|
|
||||||
// 添加到LRU缓存
|
|
||||||
contentHash := sha256.Sum256(body)
|
|
||||||
cm.lruCache.Put(cacheKey, &CacheItem{
|
|
||||||
FilePath: "", // 文件缓存,这里不需要
|
|
||||||
ContentType: "application/json",
|
|
||||||
ContentEncoding: "gzip",
|
|
||||||
Size: int64(len(body)),
|
|
||||||
LastAccess: time.Now(),
|
|
||||||
Hash: hex.EncodeToString(contentHash[:]),
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
AccessCount: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
log.Printf("[Cache] PREWARM %s", cacheKey.URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
64
internal/cache/manager_test.go
vendored
64
internal/cache/manager_test.go
vendored
@ -1,64 +0,0 @@
|
|||||||
package cache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCacheExpiry(t *testing.T) {
|
|
||||||
// 创建临时目录用于测试
|
|
||||||
tempDir, err := os.MkdirTemp("", "cache-test-*")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal("Failed to create temp dir:", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
// 创建缓存管理器,设置较短的过期时间(5秒)用于测试
|
|
||||||
cm, err := NewCacheManager(tempDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal("Failed to create cache manager:", err)
|
|
||||||
}
|
|
||||||
cm.maxAge = 5 * time.Second
|
|
||||||
|
|
||||||
// 创建测试请求和响应
|
|
||||||
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Header: make(http.Header),
|
|
||||||
}
|
|
||||||
testData := []byte("test data")
|
|
||||||
|
|
||||||
// 生成缓存键
|
|
||||||
key := cm.GenerateCacheKey(req)
|
|
||||||
|
|
||||||
// 1. 首先放入缓存
|
|
||||||
_, err = cm.Put(key, resp, testData)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal("Failed to put item in cache:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 立即获取,应该能命中
|
|
||||||
if _, hit, _ := cm.Get(key, req); !hit {
|
|
||||||
t.Error("Cache should hit immediately after putting")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 等待3秒(未过期),再次访问
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
if _, hit, _ := cm.Get(key, req); !hit {
|
|
||||||
t.Error("Cache should hit after 3 seconds")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 再等待3秒(总共6秒,但因为上次访问重置了时间,所以应该还在有效期内)
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
if _, hit, _ := cm.Get(key, req); !hit {
|
|
||||||
t.Error("Cache should hit after 6 seconds because last access reset the timer")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 等待6秒(超过过期时间且无访问),这次应该过期
|
|
||||||
time.Sleep(6 * time.Second)
|
|
||||||
if _, hit, _ := cm.Get(key, req); hit {
|
|
||||||
t.Error("Cache should expire after 6 seconds of no access")
|
|
||||||
}
|
|
||||||
}
|
|
@ -25,8 +25,9 @@ type Metrics struct {
|
|||||||
MemoryUsage string `json:"memory_usage"`
|
MemoryUsage string `json:"memory_usage"`
|
||||||
|
|
||||||
// 性能指标
|
// 性能指标
|
||||||
AverageResponseTime string `json:"avg_response_time"`
|
AverageResponseTime string `json:"avg_response_time"`
|
||||||
RequestsPerSecond float64 `json:"requests_per_second"`
|
RequestsPerSecond float64 `json:"requests_per_second"`
|
||||||
|
CurrentSessionRequests int64 `json:"current_session_requests"`
|
||||||
|
|
||||||
// 传输指标
|
// 传输指标
|
||||||
TotalBytes int64 `json:"total_bytes"`
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
@ -61,20 +62,21 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if stats == nil {
|
if stats == nil {
|
||||||
stats = map[string]interface{}{
|
stats = map[string]interface{}{
|
||||||
"uptime": metrics.FormatUptime(uptime),
|
"uptime": metrics.FormatUptime(uptime),
|
||||||
"active_requests": int64(0),
|
"active_requests": int64(0),
|
||||||
"total_requests": int64(0),
|
"total_requests": int64(0),
|
||||||
"total_errors": int64(0),
|
"total_errors": int64(0),
|
||||||
"error_rate": float64(0),
|
"error_rate": float64(0),
|
||||||
"num_goroutine": runtime.NumGoroutine(),
|
"num_goroutine": runtime.NumGoroutine(),
|
||||||
"memory_usage": "0 B",
|
"memory_usage": "0 B",
|
||||||
"avg_response_time": "0 ms",
|
"avg_response_time": "0 ms",
|
||||||
"total_bytes": int64(0),
|
"total_bytes": int64(0),
|
||||||
"bytes_per_second": float64(0),
|
"bytes_per_second": float64(0),
|
||||||
"requests_per_second": float64(0),
|
"requests_per_second": float64(0),
|
||||||
"status_code_stats": make(map[string]int64),
|
"current_session_requests": int64(0),
|
||||||
"recent_requests": make([]models.RequestLog, 0),
|
"status_code_stats": make(map[string]int64),
|
||||||
"top_referers": make([]models.PathMetrics, 0),
|
"recent_requests": make([]models.RequestLog, 0),
|
||||||
|
"top_referers": make([]models.PathMetrics, 0),
|
||||||
"latency_stats": map[string]interface{}{
|
"latency_stats": map[string]interface{}{
|
||||||
"min": "0ms",
|
"min": "0ms",
|
||||||
"max": "0ms",
|
"max": "0ms",
|
||||||
@ -108,22 +110,23 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
statusCodeStats := models.SafeStatusCodeStats(stats["status_code_stats"])
|
statusCodeStats := models.SafeStatusCodeStats(stats["status_code_stats"])
|
||||||
|
|
||||||
metrics := Metrics{
|
metrics := Metrics{
|
||||||
Uptime: metrics.FormatUptime(uptime),
|
Uptime: metrics.FormatUptime(uptime),
|
||||||
ActiveRequests: utils.SafeInt64(stats["active_requests"]),
|
ActiveRequests: utils.SafeInt64(stats["active_requests"]),
|
||||||
TotalRequests: totalRequests,
|
TotalRequests: totalRequests,
|
||||||
TotalErrors: totalErrors,
|
TotalErrors: totalErrors,
|
||||||
ErrorRate: float64(totalErrors) / float64(utils.Max(totalRequests, 1)),
|
ErrorRate: float64(totalErrors) / float64(utils.Max(totalRequests, 1)),
|
||||||
NumGoroutine: utils.SafeInt(stats["num_goroutine"]),
|
NumGoroutine: utils.SafeInt(stats["num_goroutine"]),
|
||||||
MemoryUsage: utils.SafeString(stats["memory_usage"], "0 B"),
|
MemoryUsage: utils.SafeString(stats["memory_usage"], "0 B"),
|
||||||
AverageResponseTime: utils.SafeString(stats["avg_response_time"], "0 ms"),
|
AverageResponseTime: utils.SafeString(stats["avg_response_time"], "0 ms"),
|
||||||
TotalBytes: totalBytes,
|
TotalBytes: totalBytes,
|
||||||
BytesPerSecond: float64(totalBytes) / utils.MaxFloat64(uptimeSeconds, 1),
|
BytesPerSecond: float64(totalBytes) / utils.MaxFloat64(uptimeSeconds, 1),
|
||||||
RequestsPerSecond: float64(totalRequests) / utils.MaxFloat64(uptimeSeconds, 1),
|
RequestsPerSecond: utils.SafeFloat64(stats["requests_per_second"]),
|
||||||
StatusCodeStats: statusCodeStats,
|
CurrentSessionRequests: utils.SafeInt64(stats["current_session_requests"]),
|
||||||
RecentRequests: models.SafeRequestLogs(stats["recent_requests"]),
|
StatusCodeStats: statusCodeStats,
|
||||||
TopReferers: models.SafePathMetrics(stats["top_referers"]),
|
RecentRequests: models.SafeRequestLogs(stats["recent_requests"]),
|
||||||
BandwidthHistory: bandwidthHistory,
|
TopReferers: models.SafePathMetrics(stats["top_referers"]),
|
||||||
CurrentBandwidth: utils.SafeString(stats["current_bandwidth"], "0 B/s"),
|
BandwidthHistory: bandwidthHistory,
|
||||||
|
CurrentBandwidth: utils.SafeString(stats["current_bandwidth"], "0 B/s"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 填充延迟统计数据
|
// 填充延迟统计数据
|
||||||
|
@ -120,6 +120,26 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
actualURL += "?" + r.URL.RawQuery
|
actualURL += "?" + r.URL.RawQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 早期缓存检查 - 只对GET请求进行缓存检查
|
||||||
|
if r.Method == http.MethodGet && h.Cache != nil {
|
||||||
|
cacheKey := h.Cache.GenerateCacheKey(r)
|
||||||
|
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
|
||||||
|
// 从缓存提供响应
|
||||||
|
w.Header().Set("Content-Type", item.ContentType)
|
||||||
|
if item.ContentEncoding != "" {
|
||||||
|
w.Header().Set("Content-Encoding", item.ContentEncoding)
|
||||||
|
}
|
||||||
|
w.Header().Set("Proxy-Go-Cache-HIT", "1")
|
||||||
|
if notModified {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, item.FilePath)
|
||||||
|
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(startTime), item.Size, iputil.GetClientIP(r), r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析目标 URL 以获取 host
|
// 解析目标 URL 以获取 host
|
||||||
parsedURL, err := url.Parse(actualURL)
|
parsedURL, err := url.Parse(actualURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -162,26 +182,6 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxyReq.Header.Set("Host", parsedURL.Host)
|
proxyReq.Header.Set("Host", parsedURL.Host)
|
||||||
proxyReq.Host = parsedURL.Host
|
proxyReq.Host = parsedURL.Host
|
||||||
|
|
||||||
// 检查是否可以使用缓存
|
|
||||||
if r.Method == http.MethodGet && h.Cache != nil {
|
|
||||||
cacheKey := h.Cache.GenerateCacheKey(r)
|
|
||||||
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
|
|
||||||
// 从缓存提供响应
|
|
||||||
w.Header().Set("Content-Type", item.ContentType)
|
|
||||||
if item.ContentEncoding != "" {
|
|
||||||
w.Header().Set("Content-Encoding", item.ContentEncoding)
|
|
||||||
}
|
|
||||||
w.Header().Set("Proxy-Go-Cache-HIT", "1")
|
|
||||||
if notModified {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.ServeFile(w, r, item.FilePath)
|
|
||||||
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(startTime), item.Size, iputil.GetClientIP(r), r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
resp, err := h.client.Do(proxyReq)
|
resp, err := h.client.Do(proxyReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -262,6 +262,28 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 早期缓存检查 - 只对GET请求进行缓存检查
|
||||||
|
if r.Method == http.MethodGet && h.Cache != nil {
|
||||||
|
cacheKey := h.Cache.GenerateCacheKey(r)
|
||||||
|
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
|
||||||
|
// 从缓存提供响应
|
||||||
|
w.Header().Set("Content-Type", item.ContentType)
|
||||||
|
if item.ContentEncoding != "" {
|
||||||
|
w.Header().Set("Content-Encoding", item.ContentEncoding)
|
||||||
|
}
|
||||||
|
w.Header().Set("Proxy-Go-Cache-HIT", "1")
|
||||||
|
w.Header().Set("Proxy-Go-AltTarget", "0") // 缓存命中时设为0
|
||||||
|
|
||||||
|
if notModified {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, item.FilePath)
|
||||||
|
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(start), item.Size, iputil.GetClientIP(r), r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 构建目标 URL
|
// 构建目标 URL
|
||||||
targetPath := strings.TrimPrefix(r.URL.Path, matchedPrefix)
|
targetPath := strings.TrimPrefix(r.URL.Path, matchedPrefix)
|
||||||
|
|
||||||
@ -384,33 +406,6 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否可以使用缓存
|
|
||||||
if r.Method == http.MethodGet && h.Cache != nil {
|
|
||||||
cacheKey := h.Cache.GenerateCacheKey(r)
|
|
||||||
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
|
|
||||||
// 从缓存提供响应
|
|
||||||
w.Header().Set("Content-Type", item.ContentType)
|
|
||||||
if item.ContentEncoding != "" {
|
|
||||||
w.Header().Set("Content-Encoding", item.ContentEncoding)
|
|
||||||
}
|
|
||||||
w.Header().Set("Proxy-Go-Cache-HIT", "1")
|
|
||||||
|
|
||||||
// 如果使用了扩展名映射的备用目标,添加标记响应头
|
|
||||||
if usedAltTarget {
|
|
||||||
w.Header().Set("Proxy-Go-AltTarget", "1")
|
|
||||||
}
|
|
||||||
w.Header().Set("Proxy-Go-AltTarget", "0")
|
|
||||||
|
|
||||||
if notModified {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.ServeFile(w, r, item.FilePath)
|
|
||||||
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(start), item.Size, iputil.GetClientIP(r), r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送代理请求
|
// 发送代理请求
|
||||||
resp, err := h.client.Do(proxyReq)
|
resp, err := h.client.Do(proxyReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -432,8 +427,9 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 如果使用了扩展名映射的备用目标,添加标记响应头
|
// 如果使用了扩展名映射的备用目标,添加标记响应头
|
||||||
if usedAltTarget {
|
if usedAltTarget {
|
||||||
w.Header().Set("Proxy-Go-AltTarget", "1")
|
w.Header().Set("Proxy-Go-AltTarget", "1")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Proxy-Go-AltTarget", "0")
|
||||||
}
|
}
|
||||||
w.Header().Set("Proxy-Go-AltTarget", "0")
|
|
||||||
|
|
||||||
// 设置响应状态码
|
// 设置响应状态码
|
||||||
w.WriteHeader(resp.StatusCode)
|
w.WriteHeader(resp.StatusCode)
|
||||||
@ -501,9 +497,17 @@ func copyHeader(dst, src http.Header) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用局部 map 快速查找,跳过 hop-by-hop 头部
|
// 添加需要过滤的安全头部
|
||||||
|
securityHeaders := map[string]bool{
|
||||||
|
"Content-Security-Policy": true,
|
||||||
|
"Content-Security-Policy-Report-Only": true,
|
||||||
|
"X-Content-Security-Policy": true,
|
||||||
|
"X-WebKit-CSP": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用局部 map 快速查找,跳过 hop-by-hop 头部和安全头部
|
||||||
for k, vv := range src {
|
for k, vv := range src {
|
||||||
if !hopHeaders[k] {
|
if !hopHeaders[k] && !securityHeaders[k] {
|
||||||
for _, v := range vv {
|
for _, v := range vv {
|
||||||
dst.Add(k, v)
|
dst.Add(k, v)
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"proxy-go/internal/utils"
|
"proxy-go/internal/utils"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@ -186,6 +187,19 @@ type Collector struct {
|
|||||||
}
|
}
|
||||||
recentRequests *models.RequestQueue
|
recentRequests *models.RequestQueue
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
|
||||||
|
// 新增:当前会话统计
|
||||||
|
sessionRequests int64 // 当前会话的请求数(不包含历史数据)
|
||||||
|
|
||||||
|
// 新增:基于时间窗口的请求统计
|
||||||
|
requestsWindow struct {
|
||||||
|
sync.RWMutex
|
||||||
|
window time.Duration // 时间窗口大小(5分钟)
|
||||||
|
buckets []int64 // 时间桶,每个桶统计10秒内的请求数
|
||||||
|
bucketSize time.Duration // 每个桶的时间长度(10秒)
|
||||||
|
lastUpdate time.Time // 最后更新时间
|
||||||
|
current int64 // 当前桶的请求数
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestMetric struct {
|
type RequestMetric struct {
|
||||||
@ -222,6 +236,13 @@ func InitCollector(cfg *config.Config) error {
|
|||||||
instance.bandwidthStats.lastUpdate = time.Now()
|
instance.bandwidthStats.lastUpdate = time.Now()
|
||||||
instance.bandwidthStats.history = make(map[string]int64)
|
instance.bandwidthStats.history = make(map[string]int64)
|
||||||
|
|
||||||
|
// 初始化请求窗口统计(5分钟窗口,10秒一个桶,共30个桶)
|
||||||
|
instance.requestsWindow.window = 5 * time.Minute
|
||||||
|
instance.requestsWindow.bucketSize = 10 * time.Second
|
||||||
|
bucketCount := int(instance.requestsWindow.window / instance.requestsWindow.bucketSize)
|
||||||
|
instance.requestsWindow.buckets = make([]int64, bucketCount)
|
||||||
|
instance.requestsWindow.lastUpdate = time.Now()
|
||||||
|
|
||||||
// 初始化延迟分布桶
|
// 初始化延迟分布桶
|
||||||
buckets := []string{"lt10ms", "10-50ms", "50-200ms", "200-1000ms", "gt1s"}
|
buckets := []string{"lt10ms", "10-50ms", "50-200ms", "200-1000ms", "gt1s"}
|
||||||
for _, bucket := range buckets {
|
for _, bucket := range buckets {
|
||||||
@ -318,9 +339,14 @@ func (c *Collector) GetStats() map[string]interface{} {
|
|||||||
|
|
||||||
// 计算总请求数和平均延迟
|
// 计算总请求数和平均延迟
|
||||||
var totalRequests int64
|
var totalRequests int64
|
||||||
|
var totalErrors int64
|
||||||
statusCodeStats := c.statusCodeStats.GetStats()
|
statusCodeStats := c.statusCodeStats.GetStats()
|
||||||
for _, count := range statusCodeStats {
|
for statusCode, count := range statusCodeStats {
|
||||||
totalRequests += count
|
totalRequests += count
|
||||||
|
// 计算错误数(4xx和5xx状态码)
|
||||||
|
if code, err := strconv.Atoi(statusCode); err == nil && code >= 400 {
|
||||||
|
totalErrors += count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
avgLatency := float64(0)
|
avgLatency := float64(0)
|
||||||
@ -328,8 +354,17 @@ func (c *Collector) GetStats() map[string]interface{} {
|
|||||||
avgLatency = float64(atomic.LoadInt64(&c.latencySum)) / float64(totalRequests)
|
avgLatency = float64(atomic.LoadInt64(&c.latencySum)) / float64(totalRequests)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算总体平均每秒请求数
|
// 计算错误率
|
||||||
requestsPerSecond := float64(totalRequests) / totalRuntime.Seconds()
|
errorRate := float64(0)
|
||||||
|
if totalRequests > 0 {
|
||||||
|
errorRate = float64(totalErrors) / float64(totalRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算当前会话的请求数(基于本次启动后的实际请求)
|
||||||
|
sessionRequests := atomic.LoadInt64(&c.sessionRequests)
|
||||||
|
|
||||||
|
// 计算最近5分钟的平均每秒请求数
|
||||||
|
requestsPerSecond := c.getRecentRequestsPerSecond()
|
||||||
|
|
||||||
// 收集状态码统计(已经在上面获取了)
|
// 收集状态码统计(已经在上面获取了)
|
||||||
|
|
||||||
@ -396,6 +431,9 @@ func (c *Collector) GetStats() map[string]interface{} {
|
|||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"uptime": FormatUptime(totalRuntime),
|
"uptime": FormatUptime(totalRuntime),
|
||||||
"active_requests": atomic.LoadInt64(&c.activeRequests),
|
"active_requests": atomic.LoadInt64(&c.activeRequests),
|
||||||
|
"total_requests": totalRequests,
|
||||||
|
"total_errors": totalErrors,
|
||||||
|
"error_rate": errorRate,
|
||||||
"total_bytes": atomic.LoadInt64(&c.totalBytes),
|
"total_bytes": atomic.LoadInt64(&c.totalBytes),
|
||||||
"num_goroutine": runtime.NumGoroutine(),
|
"num_goroutine": runtime.NumGoroutine(),
|
||||||
"memory_usage": utils.FormatBytes(int64(mem.Alloc)),
|
"memory_usage": utils.FormatBytes(int64(mem.Alloc)),
|
||||||
@ -410,8 +448,9 @@ func (c *Collector) GetStats() map[string]interface{} {
|
|||||||
"max": fmt.Sprintf("%.2fms", float64(maxLatency)/float64(time.Millisecond)),
|
"max": fmt.Sprintf("%.2fms", float64(maxLatency)/float64(time.Millisecond)),
|
||||||
"distribution": latencyDistribution,
|
"distribution": latencyDistribution,
|
||||||
},
|
},
|
||||||
"bandwidth_history": bandwidthHistory,
|
"bandwidth_history": bandwidthHistory,
|
||||||
"current_bandwidth": utils.FormatBytes(int64(c.getCurrentBandwidth())) + "/s",
|
"current_bandwidth": utils.FormatBytes(int64(c.getCurrentBandwidth())) + "/s",
|
||||||
|
"current_session_requests": sessionRequests,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -537,6 +576,76 @@ func (c *Collector) getCurrentBandwidth() float64 {
|
|||||||
return float64(c.bandwidthStats.current) / duration
|
return float64(c.bandwidthStats.current) / duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateRequestsWindow 更新请求窗口统计
|
||||||
|
func (c *Collector) updateRequestsWindow(count int64) {
|
||||||
|
c.requestsWindow.Lock()
|
||||||
|
defer c.requestsWindow.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// 如果是第一次调用,初始化时间
|
||||||
|
if c.requestsWindow.lastUpdate.IsZero() {
|
||||||
|
c.requestsWindow.lastUpdate = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算当前时间桶的索引
|
||||||
|
timeSinceLastUpdate := now.Sub(c.requestsWindow.lastUpdate)
|
||||||
|
|
||||||
|
// 如果时间跨度超过桶大小,需要移动到新桶
|
||||||
|
if timeSinceLastUpdate >= c.requestsWindow.bucketSize {
|
||||||
|
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.lastUpdate = now.Truncate(c.requestsWindow.bucketSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将请求数加到第一个桶(当前时间桶)
|
||||||
|
if len(c.requestsWindow.buckets) > 0 {
|
||||||
|
c.requestsWindow.buckets[0] += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRecentRequestsPerSecond 获取最近5分钟的平均每秒请求数
|
||||||
|
func (c *Collector) getRecentRequestsPerSecond() float64 {
|
||||||
|
c.requestsWindow.RLock()
|
||||||
|
defer c.requestsWindow.RUnlock()
|
||||||
|
|
||||||
|
// 统计所有桶的总请求数
|
||||||
|
var totalRequests int64
|
||||||
|
for _, bucket := range c.requestsWindow.buckets {
|
||||||
|
totalRequests += bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算实际的时间窗口(可能不满5分钟)
|
||||||
|
now := time.Now()
|
||||||
|
actualWindow := c.requestsWindow.window
|
||||||
|
|
||||||
|
// 如果程序运行时间不足5分钟,使用实际运行时间
|
||||||
|
if runTime := now.Sub(c.startTime); runTime < c.requestsWindow.window {
|
||||||
|
actualWindow = runTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if actualWindow.Seconds() == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return float64(totalRequests) / actualWindow.Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
// getBandwidthHistory 获取带宽历史记录
|
// getBandwidthHistory 获取带宽历史记录
|
||||||
func (c *Collector) getBandwidthHistory() map[string]string {
|
func (c *Collector) getBandwidthHistory() map[string]string {
|
||||||
c.bandwidthStats.RLock()
|
c.bandwidthStats.RLock()
|
||||||
@ -599,6 +708,12 @@ func (c *Collector) startAsyncMetricsUpdater() {
|
|||||||
// 批量更新指标
|
// 批量更新指标
|
||||||
func (c *Collector) updateMetricsBatch(batch []RequestMetric) {
|
func (c *Collector) updateMetricsBatch(batch []RequestMetric) {
|
||||||
for _, m := range batch {
|
for _, m := range batch {
|
||||||
|
// 增加当前会话请求计数
|
||||||
|
atomic.AddInt64(&c.sessionRequests, 1)
|
||||||
|
|
||||||
|
// 更新请求窗口统计
|
||||||
|
c.updateRequestsWindow(1)
|
||||||
|
|
||||||
// 更新状态码统计
|
// 更新状态码统计
|
||||||
c.statusCodeStats.Increment(m.Status)
|
c.statusCodeStats.Increment(m.Status)
|
||||||
|
|
||||||
|
@ -459,6 +459,26 @@ func SafeString(v interface{}, defaultValue string) string {
|
|||||||
return defaultValue
|
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 中的较大值
|
// Max 返回两个 int64 中的较大值
|
||||||
func Max(a, b int64) int64 {
|
func Max(a, b int64) int64 {
|
||||||
if a > b {
|
if a > b {
|
||||||
|
469
web/app/dashboard/cache/page.tsx
vendored
469
web/app/dashboard/cache/page.tsx
vendored
@ -7,7 +7,24 @@ import { useToast } from "@/components/ui/use-toast"
|
|||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { useRouter } from "next/navigation"
|
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 {
|
interface CacheStats {
|
||||||
total_items: number
|
total_items: number
|
||||||
@ -17,6 +34,9 @@ interface CacheStats {
|
|||||||
hit_rate: number
|
hit_rate: number
|
||||||
bytes_saved: number
|
bytes_saved: number
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
format_fallback_hit: number
|
||||||
|
image_cache_hit: number
|
||||||
|
regular_cache_hit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CacheConfig {
|
interface CacheConfig {
|
||||||
@ -256,15 +276,19 @@ export default function CachePage() {
|
|||||||
|
|
||||||
const config = configs[type]
|
const config = configs[type]
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4 p-4 bg-gray-50 rounded-lg border">
|
||||||
<h3 className="text-sm font-medium">缓存配置</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4 text-gray-600" />
|
||||||
|
<h3 className="text-sm font-medium text-gray-800">缓存配置</h3>
|
||||||
|
</div>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid grid-cols-2 items-center gap-4">
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
<Label htmlFor={`${type}-max-age`}>最大缓存时间(分钟)</Label>
|
<Label htmlFor={`${type}-max-age`} className="text-sm">最大缓存时间(分钟)</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${type}-max-age`}
|
id={`${type}-max-age`}
|
||||||
type="number"
|
type="number"
|
||||||
value={config.max_age}
|
value={config.max_age}
|
||||||
|
className="h-8"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newConfigs = { ...configs }
|
const newConfigs = { ...configs }
|
||||||
newConfigs[type].max_age = parseInt(e.target.value)
|
newConfigs[type].max_age = parseInt(e.target.value)
|
||||||
@ -274,11 +298,12 @@ export default function CachePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 items-center gap-4">
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
<Label htmlFor={`${type}-cleanup-tick`}>清理间隔(分钟)</Label>
|
<Label htmlFor={`${type}-cleanup-tick`} className="text-sm">清理间隔(分钟)</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${type}-cleanup-tick`}
|
id={`${type}-cleanup-tick`}
|
||||||
type="number"
|
type="number"
|
||||||
value={config.cleanup_tick}
|
value={config.cleanup_tick}
|
||||||
|
className="h-8"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newConfigs = { ...configs }
|
const newConfigs = { ...configs }
|
||||||
newConfigs[type].cleanup_tick = parseInt(e.target.value)
|
newConfigs[type].cleanup_tick = parseInt(e.target.value)
|
||||||
@ -288,11 +313,12 @@ export default function CachePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 items-center gap-4">
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
<Label htmlFor={`${type}-max-cache-size`}>最大缓存大小(GB)</Label>
|
<Label htmlFor={`${type}-max-cache-size`} className="text-sm">最大缓存大小(GB)</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${type}-max-cache-size`}
|
id={`${type}-max-cache-size`}
|
||||||
type="number"
|
type="number"
|
||||||
value={config.max_cache_size}
|
value={config.max_cache_size}
|
||||||
|
className="h-8"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newConfigs = { ...configs }
|
const newConfigs = { ...configs }
|
||||||
newConfigs[type].max_cache_size = parseInt(e.target.value)
|
newConfigs[type].max_cache_size = parseInt(e.target.value)
|
||||||
@ -310,6 +336,7 @@ export default function CachePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-blue-500" />
|
||||||
<div className="text-lg font-medium">加载中...</div>
|
<div className="text-lg font-medium">加载中...</div>
|
||||||
<div className="text-sm text-gray-500 mt-1">正在获取缓存统计信息</div>
|
<div className="text-sm text-gray-500 mt-1">正在获取缓存统计信息</div>
|
||||||
</div>
|
</div>
|
||||||
@ -318,112 +345,352 @@ export default function CachePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<TooltipProvider>
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="space-y-6">
|
||||||
<Button variant="outline" onClick={() => handleClearCache("all")}>
|
<div className="flex justify-between items-center">
|
||||||
清理所有缓存
|
<div className="flex items-center gap-2">
|
||||||
</Button>
|
<Database className="h-6 w-6 text-blue-600" />
|
||||||
</div>
|
<h1 className="text-2xl font-bold">缓存管理</h1>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleClearCache("all")}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
清理所有缓存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
{/* 智能缓存汇总 */}
|
||||||
{/* 代理缓存 */}
|
<Card className="border-2 border-blue-100 bg-gradient-to-r from-blue-50 to-purple-50">
|
||||||
<Card>
|
<CardHeader className="pb-3">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardTitle className="flex items-center gap-2 text-blue-800">
|
||||||
<CardTitle>代理缓存</CardTitle>
|
<Zap className="h-5 w-5" />
|
||||||
<div className="flex items-center space-x-2">
|
智能缓存汇总
|
||||||
<Switch
|
</CardTitle>
|
||||||
checked={stats?.proxy.enabled ?? false}
|
|
||||||
onCheckedChange={(checked) => handleToggleCache("proxy", checked)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleClearCache("proxy")}
|
|
||||||
>
|
|
||||||
清理
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<dl className="space-y-2">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="flex justify-between">
|
<Tooltip>
|
||||||
<dt className="text-sm font-medium text-gray-500">缓存项数量</dt>
|
<TooltipTrigger asChild>
|
||||||
<dd className="text-sm text-gray-900">{stats?.proxy.total_items ?? 0}</dd>
|
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
|
||||||
</div>
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
<div className="flex justify-between">
|
<FileText className="h-5 w-5 text-blue-600" />
|
||||||
<dt className="text-sm font-medium text-gray-500">总大小</dt>
|
<Info className="h-3 w-3 text-gray-400" />
|
||||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.total_size ?? 0)}</dd>
|
</div>
|
||||||
</div>
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
<div className="flex justify-between">
|
{(stats?.proxy.regular_cache_hit ?? 0) + (stats?.mirror.regular_cache_hit ?? 0)}
|
||||||
<dt className="text-sm font-medium text-gray-500">命中次数</dt>
|
</div>
|
||||||
<dd className="text-sm text-gray-900">{stats?.proxy.hit_count ?? 0}</dd>
|
<div className="text-sm text-gray-600 font-medium">常规缓存命中</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
</TooltipTrigger>
|
||||||
<dt className="text-sm font-medium text-gray-500">未命中次数</dt>
|
<TooltipContent>
|
||||||
<dd className="text-sm text-gray-900">{stats?.proxy.miss_count ?? 0}</dd>
|
<p>所有常规文件的精确缓存命中总数</p>
|
||||||
</div>
|
</TooltipContent>
|
||||||
<div className="flex justify-between">
|
</Tooltip>
|
||||||
<dt className="text-sm font-medium text-gray-500">命中率</dt>
|
|
||||||
<dd className="text-sm text-gray-900">{(stats?.proxy.hit_rate ?? 0).toFixed(2)}%</dd>
|
<Tooltip>
|
||||||
</div>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex justify-between">
|
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
|
||||||
<dt className="text-sm font-medium text-gray-500">节省带宽</dt>
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd>
|
<ImageIcon className="h-5 w-5 text-green-600" aria-hidden="true" />
|
||||||
</div>
|
<Info className="h-3 w-3 text-gray-400" />
|
||||||
</dl>
|
</div>
|
||||||
{renderCacheConfig("proxy")}
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{(stats?.proxy.image_cache_hit ?? 0) + (stats?.mirror.image_cache_hit ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 font-medium">图片精确命中</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>所有图片文件的精确格式缓存命中总数</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
|
<RotateCcw className="h-5 w-5 text-orange-600" />
|
||||||
|
<Info className="h-3 w-3 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-orange-600">
|
||||||
|
{(stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 font-medium">格式回退命中</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>图片格式回退命中总数,提高了缓存效率</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
|
<Target className="h-5 w-5 text-purple-600" />
|
||||||
|
<Info className="h-3 w-3 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
|
{(() => {
|
||||||
|
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'
|
||||||
|
})()}%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 font-medium">格式回退率</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>格式回退在所有图片请求中的占比,显示智能缓存的效果</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 镜像缓存 */}
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
{/* 代理缓存 */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
<CardTitle>镜像缓存</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<div className="flex items-center space-x-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Switch
|
<HardDrive className="h-5 w-5 text-blue-600" />
|
||||||
checked={stats?.mirror.enabled ?? false}
|
代理缓存
|
||||||
onCheckedChange={(checked) => handleToggleCache("mirror", checked)}
|
</CardTitle>
|
||||||
/>
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Switch
|
||||||
variant="outline"
|
checked={stats?.proxy.enabled ?? false}
|
||||||
size="sm"
|
onCheckedChange={(checked) => handleToggleCache("proxy", checked)}
|
||||||
onClick={() => handleClearCache("mirror")}
|
/>
|
||||||
>
|
<Button
|
||||||
清理
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
</div>
|
onClick={() => handleClearCache("proxy")}
|
||||||
</CardHeader>
|
className="flex items-center gap-1"
|
||||||
<CardContent>
|
>
|
||||||
<dl className="space-y-2">
|
<Trash2 className="h-3 w-3" />
|
||||||
<div className="flex justify-between">
|
清理
|
||||||
<dt className="text-sm font-medium text-gray-500">缓存项数量</dt>
|
</Button>
|
||||||
<dd className="text-sm text-gray-900">{stats?.mirror.total_items ?? 0}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
</CardHeader>
|
||||||
<dt className="text-sm font-medium text-gray-500">总大小</dt>
|
<CardContent>
|
||||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.total_size ?? 0)}</dd>
|
<dl className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
缓存项数量
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-gray-900">{stats?.proxy.total_items ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
总大小
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-gray-900">{formatBytes(stats?.proxy.total_size ?? 0)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-green-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-green-700 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
命中次数
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-green-800">{stats?.proxy.hit_count ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-red-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-red-700 flex items-center gap-2">
|
||||||
|
<TrendingDown className="h-4 w-4" />
|
||||||
|
未命中次数
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-red-800">{stats?.proxy.miss_count ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-blue-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-blue-700 flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
命中率
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-blue-800">{(stats?.proxy.hit_rate ?? 0).toFixed(2)}%</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-purple-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-purple-700 flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
节省带宽
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-purple-800">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Zap className="h-4 w-4 text-gray-600" />
|
||||||
|
<div className="text-sm font-medium text-gray-800">智能缓存统计</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-center p-3 bg-blue-50 rounded-lg border cursor-help hover:bg-blue-100 transition-colors">
|
||||||
|
<FileText className="h-4 w-4 mx-auto mb-1 text-blue-600" />
|
||||||
|
<div className="text-lg font-bold text-blue-600">{stats?.proxy.regular_cache_hit ?? 0}</div>
|
||||||
|
<div className="text-xs text-blue-700">常规命中</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>常规文件的精确缓存命中</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-center p-3 bg-green-50 rounded-lg border cursor-help hover:bg-green-100 transition-colors">
|
||||||
|
<ImageIcon className="h-4 w-4 mx-auto mb-1 text-green-600" aria-hidden="true" />
|
||||||
|
<div className="text-lg font-bold text-green-600">{stats?.proxy.image_cache_hit ?? 0}</div>
|
||||||
|
<div className="text-xs text-green-700">图片命中</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>图片文件的精确格式缓存命中</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-center p-3 bg-orange-50 rounded-lg border cursor-help hover:bg-orange-100 transition-colors">
|
||||||
|
<RotateCcw className="h-4 w-4 mx-auto mb-1 text-orange-600" />
|
||||||
|
<div className="text-lg font-bold text-orange-600">{stats?.proxy.format_fallback_hit ?? 0}</div>
|
||||||
|
<div className="text-xs text-orange-700">格式回退</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>图片格式回退命中(如请求WebP但提供JPEG)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
{renderCacheConfig("proxy")}
|
||||||
<dt className="text-sm font-medium text-gray-500">命中次数</dt>
|
</CardContent>
|
||||||
<dd className="text-sm text-gray-900">{stats?.mirror.hit_count ?? 0}</dd>
|
</Card>
|
||||||
|
|
||||||
|
{/* 镜像缓存 */}
|
||||||
|
<Card className="border-l-4 border-l-green-500">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5 text-green-600" />
|
||||||
|
镜像缓存
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={stats?.mirror.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => handleToggleCache("mirror", checked)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleClearCache("mirror")}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
清理
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
</CardHeader>
|
||||||
<dt className="text-sm font-medium text-gray-500">未命中次数</dt>
|
<CardContent>
|
||||||
<dd className="text-sm text-gray-900">{stats?.mirror.miss_count ?? 0}</dd>
|
<dl className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
缓存项数量
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-gray-900">{stats?.mirror.total_items ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<HardDrive className="h-4 w-4" />
|
||||||
|
总大小
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-gray-900">{formatBytes(stats?.mirror.total_size ?? 0)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-green-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-green-700 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
命中次数
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-green-800">{stats?.mirror.hit_count ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-red-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-red-700 flex items-center gap-2">
|
||||||
|
<TrendingDown className="h-4 w-4" />
|
||||||
|
未命中次数
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-red-800">{stats?.mirror.miss_count ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-blue-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-blue-700 flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
命中率
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-blue-800">{(stats?.mirror.hit_rate ?? 0).toFixed(2)}%</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center p-2 bg-purple-50 rounded">
|
||||||
|
<dt className="text-sm font-medium text-purple-700 flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
节省带宽
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm font-semibold text-purple-800">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Zap className="h-4 w-4 text-gray-600" />
|
||||||
|
<div className="text-sm font-medium text-gray-800">智能缓存统计</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-center p-3 bg-blue-50 rounded-lg border cursor-help hover:bg-blue-100 transition-colors">
|
||||||
|
<FileText className="h-4 w-4 mx-auto mb-1 text-blue-600" />
|
||||||
|
<div className="text-lg font-bold text-blue-600">{stats?.mirror.regular_cache_hit ?? 0}</div>
|
||||||
|
<div className="text-xs text-blue-700">常规命中</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>常规文件的精确缓存命中</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-center p-3 bg-green-50 rounded-lg border cursor-help hover:bg-green-100 transition-colors">
|
||||||
|
<ImageIcon className="h-4 w-4 mx-auto mb-1 text-green-600" aria-hidden="true" />
|
||||||
|
<div className="text-lg font-bold text-green-600">{stats?.mirror.image_cache_hit ?? 0}</div>
|
||||||
|
<div className="text-xs text-green-700">图片命中</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>图片文件的精确格式缓存命中</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-center p-3 bg-orange-50 rounded-lg border cursor-help hover:bg-orange-100 transition-colors">
|
||||||
|
<RotateCcw className="h-4 w-4 mx-auto mb-1 text-orange-600" />
|
||||||
|
<div className="text-lg font-bold text-orange-600">{stats?.mirror.format_fallback_hit ?? 0}</div>
|
||||||
|
<div className="text-xs text-orange-700">格式回退</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>图片格式回退命中(如请求WebP但提供JPEG)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
{renderCacheConfig("mirror")}
|
||||||
<dt className="text-sm font-medium text-gray-500">命中率</dt>
|
</CardContent>
|
||||||
<dd className="text-sm text-gray-900">{(stats?.mirror.hit_rate ?? 0).toFixed(2)}%</dd>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-sm font-medium text-gray-500">节省带宽</dt>
|
|
||||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{renderCacheConfig("mirror")}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -34,13 +34,14 @@ interface Metrics {
|
|||||||
bandwidth_history: Record<string, string>
|
bandwidth_history: Record<string, string>
|
||||||
current_bandwidth: string
|
current_bandwidth: string
|
||||||
total_bytes: number
|
total_bytes: number
|
||||||
|
current_session_requests: number
|
||||||
top_referers: Array<{
|
top_referers: Array<{
|
||||||
path: string
|
path: string
|
||||||
request_count: number
|
request_count: number
|
||||||
error_count: number
|
error_count: number
|
||||||
avg_latency: string
|
avg_latency: string
|
||||||
bytes_transferred: number
|
bytes_transferred: number
|
||||||
last_access_time: number // 添加最后访问时间字段
|
last_access_time: number
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +94,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMetrics()
|
fetchMetrics()
|
||||||
const interval = setInterval(fetchMetrics, 3000)
|
const interval = setInterval(fetchMetrics, 1000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [fetchMetrics])
|
}, [fetchMetrics])
|
||||||
|
|
||||||
@ -146,6 +147,18 @@ export default function DashboardPage() {
|
|||||||
<div className="text-sm font-medium text-gray-500">当前活跃请求</div>
|
<div className="text-sm font-medium text-gray-500">当前活跃请求</div>
|
||||||
<div className="text-lg font-semibold">{metrics.active_requests}</div>
|
<div className="text-lg font-semibold">{metrics.active_requests}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">总请求数</div>
|
||||||
|
<div className="text-lg font-semibold">{metrics.total_requests || Object.values(metrics.status_code_stats || {}).reduce((a, b) => a + (b as number), 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">总错误数</div>
|
||||||
|
<div className="text-lg font-semibold text-red-600">{metrics.total_errors || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">错误率</div>
|
||||||
|
<div className="text-lg font-semibold text-red-600">{((metrics.error_rate || 0) * 100).toFixed(2)}%</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-500">总传输数据</div>
|
<div className="text-sm font-medium text-gray-500">总传输数据</div>
|
||||||
<div className="text-lg font-semibold">{formatBytes(metrics.total_bytes)}</div>
|
<div className="text-lg font-semibold">{formatBytes(metrics.total_bytes)}</div>
|
||||||
@ -154,6 +167,14 @@ export default function DashboardPage() {
|
|||||||
<div className="text-sm font-medium text-gray-500">每秒传输数据</div>
|
<div className="text-sm font-medium text-gray-500">每秒传输数据</div>
|
||||||
<div className="text-lg font-semibold">{formatBytes(metrics.bytes_per_second)}/s</div>
|
<div className="text-lg font-semibold">{formatBytes(metrics.bytes_per_second)}/s</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">当前会话请求数</div>
|
||||||
|
<div className="text-lg font-semibold text-blue-600">{metrics.current_session_requests || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-500">近5分钟每秒请求数</div>
|
||||||
|
<div className="text-lg font-semibold">{(metrics.requests_per_second || 0).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -177,10 +198,8 @@ export default function DashboardPage() {
|
|||||||
<div className="text-lg font-semibold">{metrics.avg_response_time}</div>
|
<div className="text-lg font-semibold">{metrics.avg_response_time}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-500">平均每秒请求数</div>
|
<div className="text-sm font-medium text-gray-500">当前带宽</div>
|
||||||
<div className="text-lg font-semibold">
|
<div className="text-lg font-semibold">{metrics.current_bandwidth}</div>
|
||||||
{metrics.requests_per_second.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
32
web/components/ui/tooltip.tsx
Normal file
32
web/components/ui/tooltip.tsx
Normal file
@ -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<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
425
web/package-lock.json
generated
425
web/package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.475.0",
|
"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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user