feat(cache): Add dynamic cache configuration management

- Implement GetConfig and UpdateConfig methods in CacheManager
- Add cache configuration endpoints in CacheAdminHandler
- Create frontend UI for dynamically updating cache settings
- Support configurable max age, cleanup interval, and cache size
- Add input validation for cache configuration updates
This commit is contained in:
wood chen 2025-02-16 13:57:12 +08:00
parent d00ab0a6e1
commit a4c4688412
8 changed files with 360 additions and 215 deletions

View File

@ -72,10 +72,12 @@ type CacheManager struct {
maxAge time.Duration
cleanupTick time.Duration
maxCacheSize int64
enabled atomic.Bool // 缓存开关
hitCount atomic.Int64 // 命中计数
missCount atomic.Int64 // 未命中计数
bytesSaved atomic.Int64 // 节省的带宽
enabled atomic.Bool // 缓存开关
hitCount atomic.Int64 // 命中计数
missCount atomic.Int64 // 未命中计数
bytesSaved atomic.Int64 // 节省的带宽
cleanupTimer *time.Ticker // 添加清理定时器
stopCleanup chan struct{} // 添加停止信号通道
}
// NewCacheManager 创建新的缓存管理器
@ -89,12 +91,13 @@ func NewCacheManager(cacheDir string) (*CacheManager, error) {
maxAge: 30 * time.Minute,
cleanupTick: 5 * time.Minute,
maxCacheSize: 10 * 1024 * 1024 * 1024, // 10GB
stopCleanup: make(chan struct{}),
}
cm.enabled.Store(true) // 默认启用缓存
// 启动清理协程
go cm.cleanup()
cm.startCleanup()
return cm, nil
}
@ -214,60 +217,57 @@ func (cm *CacheManager) Put(key CacheKey, resp *http.Response, body []byte) (*Ca
// cleanup 定期清理过期的缓存项
func (cm *CacheManager) cleanup() {
ticker := time.NewTicker(cm.cleanupTick)
for range ticker.C {
var totalSize int64
var keysToDelete []CacheKey
var totalSize int64
var keysToDelete []CacheKey
// 收集需要删除的键和计算总大小
// 收集需要删除的键和计算总大小
cm.items.Range(func(k, v interface{}) bool {
key := k.(CacheKey)
item := v.(*CacheItem)
totalSize += item.Size
if time.Since(item.LastAccess) > cm.maxAge {
keysToDelete = append(keysToDelete, key)
}
return true
})
// 如果总大小超过限制,按最后访问时间排序删除
if totalSize > cm.maxCacheSize {
var items []*CacheItem
cm.items.Range(func(k, v interface{}) bool {
key := k.(CacheKey)
item := v.(*CacheItem)
totalSize += item.Size
if time.Since(item.LastAccess) > cm.maxAge {
keysToDelete = append(keysToDelete, key)
}
items = append(items, v.(*CacheItem))
return true
})
// 如果总大小超过限制,按最后访问时间排序删除
if totalSize > cm.maxCacheSize {
var items []*CacheItem
// 按最后访问时间排序
sort.Slice(items, func(i, j int) bool {
return items[i].LastAccess.Before(items[j].LastAccess)
})
// 删除最旧的项直到总大小小于限制
for _, item := range items {
if totalSize <= cm.maxCacheSize {
break
}
cm.items.Range(func(k, v interface{}) bool {
items = append(items, v.(*CacheItem))
if v.(*CacheItem) == item {
keysToDelete = append(keysToDelete, k.(CacheKey))
totalSize -= item.Size
return false
}
return true
})
// 按最后访问时间排序
sort.Slice(items, func(i, j int) bool {
return items[i].LastAccess.Before(items[j].LastAccess)
})
// 删除最旧的项直到总大小小于限制
for _, item := range items {
if totalSize <= cm.maxCacheSize {
break
}
cm.items.Range(func(k, v interface{}) bool {
if v.(*CacheItem) == item {
keysToDelete = append(keysToDelete, k.(CacheKey))
totalSize -= item.Size
return false
}
return true
})
}
}
}
// 删除过期和超出大小限制的缓存项
for _, key := range keysToDelete {
if item, ok := cm.items.Load(key); ok {
cacheItem := item.(*CacheItem)
os.Remove(cacheItem.FilePath)
cm.items.Delete(key)
log.Printf("[Cache] Removed expired item: %s", key.URL)
}
// 删除过期和超出大小限制的缓存项
for _, key := range keysToDelete {
if item, ok := cm.items.Load(key); ok {
cacheItem := item.(*CacheItem)
os.Remove(cacheItem.FilePath)
cm.items.Delete(key)
log.Printf("[Cache] Removed expired item: %s", key.URL)
}
}
}
@ -408,3 +408,57 @@ func (cm *CacheManager) Commit(key CacheKey, tempPath string, resp *http.Respons
log.Printf("[Cache] Cached %s (%s)", key.URL, formatBytes(size))
return nil
}
// GetConfig 获取缓存配置
func (cm *CacheManager) GetConfig() CacheConfig {
return CacheConfig{
MaxAge: int64(cm.maxAge.Minutes()),
CleanupTick: int64(cm.cleanupTick.Minutes()),
MaxCacheSize: cm.maxCacheSize / (1024 * 1024 * 1024), // 转换为GB
}
}
// UpdateConfig 更新缓存配置
func (cm *CacheManager) UpdateConfig(maxAge, cleanupTick, maxCacheSize int64) error {
if maxAge <= 0 || cleanupTick <= 0 || maxCacheSize <= 0 {
return fmt.Errorf("invalid config values: all values must be positive")
}
cm.maxAge = time.Duration(maxAge) * time.Minute
cm.maxCacheSize = maxCacheSize * 1024 * 1024 * 1024 // 转换为字节
// 如果清理间隔发生变化,重启清理协程
newCleanupTick := time.Duration(cleanupTick) * time.Minute
if cm.cleanupTick != newCleanupTick {
cm.cleanupTick = newCleanupTick
// 停止当前的清理协程
cm.stopCleanup <- struct{}{}
// 启动新的清理协程
cm.startCleanup()
}
return nil
}
// CacheConfig 缓存配置结构
type CacheConfig struct {
MaxAge int64 `json:"max_age"` // 最大缓存时间(分钟)
CleanupTick int64 `json:"cleanup_tick"` // 清理间隔(分钟)
MaxCacheSize int64 `json:"max_cache_size"` // 最大缓存大小GB
}
// startCleanup 启动清理协程
func (cm *CacheManager) startCleanup() {
cm.cleanupTimer = time.NewTicker(cm.cleanupTick)
go func() {
for {
select {
case <-cm.cleanupTimer.C:
cm.cleanup()
case <-cm.stopCleanup:
cm.cleanupTimer.Stop()
return
}
}
}()
}

View File

@ -20,6 +20,13 @@ func NewCacheAdminHandler(proxyCache, mirrorCache, fixedPathCache *cache.CacheMa
}
}
// CacheConfig 缓存配置结构
type CacheConfig struct {
MaxAge int64 `json:"max_age"` // 最大缓存时间(分钟)
CleanupTick int64 `json:"cleanup_tick"` // 清理间隔(分钟)
MaxCacheSize int64 `json:"max_cache_size"` // 最大缓存大小GB
}
// GetCacheStats 获取缓存统计信息
func (h *CacheAdminHandler) GetCacheStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@ -37,6 +44,61 @@ func (h *CacheAdminHandler) GetCacheStats(w http.ResponseWriter, r *http.Request
json.NewEncoder(w).Encode(stats)
}
// GetCacheConfig 获取缓存配置
func (h *CacheAdminHandler) GetCacheConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
configs := map[string]cache.CacheConfig{
"proxy": h.proxyCache.GetConfig(),
"mirror": h.mirrorCache.GetConfig(),
"fixedPath": h.fixedPathCache.GetConfig(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(configs)
}
// UpdateCacheConfig 更新缓存配置
func (h *CacheAdminHandler) UpdateCacheConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Type string `json:"type"` // "proxy", "mirror" 或 "fixedPath"
Config CacheConfig `json:"config"` // 新的配置
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
var targetCache *cache.CacheManager
switch req.Type {
case "proxy":
targetCache = h.proxyCache
case "mirror":
targetCache = h.mirrorCache
case "fixedPath":
targetCache = h.fixedPathCache
default:
http.Error(w, "Invalid cache type", http.StatusBadRequest)
return
}
if err := targetCache.UpdateConfig(req.Config.MaxAge, req.Config.CleanupTick, req.Config.MaxCacheSize); err != nil {
http.Error(w, "Failed to update config: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// SetCacheEnabled 设置缓存开关状态
func (h *CacheAdminHandler) SetCacheEnabled(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {

View File

@ -194,6 +194,21 @@ func (c *Collector) RecordRequest(path string, status int, latency time.Duration
c.recentRequestsMutex.Unlock()
}
// formatUptime 格式化运行时间
func formatUptime(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%d天%d小时%d分钟", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%d小时%d分钟", hours, minutes)
}
return fmt.Sprintf("%d分钟", minutes)
}
// GetStats 获取统计数据
func (c *Collector) GetStats() map[string]interface{} {
var mem runtime.MemStats
@ -274,7 +289,7 @@ func (c *Collector) GetStats() map[string]interface{} {
}
return map[string]interface{}{
"uptime": time.Since(c.startTime).String(),
"uptime": formatUptime(time.Since(c.startTime)),
"active_requests": atomic.LoadInt64(&c.activeRequests),
"total_requests": atomic.LoadInt64(&c.totalRequests),
"total_errors": atomic.LoadInt64(&c.totalErrors),

View File

@ -5,6 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
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"
interface CacheStats {
total_items: number
@ -16,12 +18,24 @@ interface CacheStats {
enabled: boolean
}
interface CacheConfig {
max_age: number
cleanup_tick: number
max_cache_size: number
}
interface CacheData {
proxy: CacheStats
mirror: CacheStats
fixedPath: CacheStats
}
interface CacheConfigs {
proxy: CacheConfig
mirror: CacheConfig
fixedPath: CacheConfig
}
function formatBytes(bytes: number) {
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
@ -37,6 +51,7 @@ function formatBytes(bytes: number) {
export default function CachePage() {
const [stats, setStats] = useState<CacheData | null>(null)
const [configs, setConfigs] = useState<CacheConfigs | null>(null)
const [loading, setLoading] = useState(true)
const { toast } = useToast()
@ -57,14 +72,30 @@ export default function CachePage() {
}
}, [toast])
const fetchConfigs = useCallback(async () => {
try {
const response = await fetch("/admin/api/cache/config")
if (!response.ok) throw new Error("获取缓存配置失败")
const data = await response.json()
setConfigs(data)
} catch (error) {
toast({
title: "错误",
description: error instanceof Error ? error.message : "获取缓存配置失败",
variant: "destructive",
})
}
}, [toast])
useEffect(() => {
// 立即获取一次数据
fetchStats()
fetchConfigs()
// 设置定时刷新
const interval = setInterval(fetchStats, 5000)
return () => clearInterval(interval)
}, [fetchStats])
}, [fetchStats, fetchConfigs])
const handleToggleCache = async (type: "proxy" | "mirror" | "fixedPath", enabled: boolean) => {
try {
@ -91,6 +122,31 @@ export default function CachePage() {
}
}
const handleUpdateConfig = async (type: "proxy" | "mirror" | "fixedPath", config: CacheConfig) => {
try {
const response = await fetch("/admin/api/cache/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, config }),
})
if (!response.ok) throw new Error("更新缓存配置失败")
toast({
title: "成功",
description: "缓存配置已更新",
})
fetchConfigs()
} catch (error) {
toast({
title: "错误",
description: error instanceof Error ? error.message : "更新缓存配置失败",
variant: "destructive",
})
}
}
const handleClearCache = async (type: "proxy" | "mirror" | "fixedPath" | "all") => {
try {
const response = await fetch("/admin/api/cache/clear", {
@ -116,6 +172,61 @@ export default function CachePage() {
}
}
const renderCacheConfig = (type: "proxy" | "mirror" | "fixedPath") => {
if (!configs) return null
const config = configs[type]
return (
<div className="space-y-4 mt-4">
<h3 className="text-sm font-medium"></h3>
<div className="grid gap-4">
<div className="grid grid-cols-2 items-center gap-4">
<Label htmlFor={`${type}-max-age`}></Label>
<Input
id={`${type}-max-age`}
type="number"
value={config.max_age}
onChange={(e) => {
const newConfigs = { ...configs }
newConfigs[type].max_age = parseInt(e.target.value)
setConfigs(newConfigs)
}}
onBlur={() => handleUpdateConfig(type, config)}
/>
</div>
<div className="grid grid-cols-2 items-center gap-4">
<Label htmlFor={`${type}-cleanup-tick`}></Label>
<Input
id={`${type}-cleanup-tick`}
type="number"
value={config.cleanup_tick}
onChange={(e) => {
const newConfigs = { ...configs }
newConfigs[type].cleanup_tick = parseInt(e.target.value)
setConfigs(newConfigs)
}}
onBlur={() => handleUpdateConfig(type, config)}
/>
</div>
<div className="grid grid-cols-2 items-center gap-4">
<Label htmlFor={`${type}-max-cache-size`}>GB</Label>
<Input
id={`${type}-max-cache-size`}
type="number"
value={config.max_cache_size}
onChange={(e) => {
const newConfigs = { ...configs }
newConfigs[type].max_cache_size = parseInt(e.target.value)
setConfigs(newConfigs)
}}
onBlur={() => handleUpdateConfig(type, config)}
/>
</div>
</div>
</div>
)
}
if (loading) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
@ -181,6 +292,7 @@ export default function CachePage() {
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd>
</div>
</dl>
{renderCacheConfig("proxy")}
</CardContent>
</Card>
@ -229,6 +341,7 @@ export default function CachePage() {
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
</div>
</dl>
{renderCacheConfig("mirror")}
</CardContent>
</Card>
@ -277,6 +390,7 @@ export default function CachePage() {
<dd className="text-sm text-gray-900">{formatBytes(stats?.fixedPath.bytes_saved ?? 0)}</dd>
</div>
</dl>
{renderCacheConfig("fixedPath")}
</CardContent>
</Card>
</div>

View File

@ -1,24 +1,9 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useState, useCallback } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
import { useRouter } from "next/navigation"
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
PieChart,
Pie,
Cell,
LineChart,
Line,
} from "recharts"
interface Metrics {
uptime: string
@ -29,6 +14,7 @@ interface Metrics {
memory_usage: string
avg_response_time: string
requests_per_second: number
bytes_per_second: number
status_code_stats: Record<string, number>
top_paths: Array<{
path: string
@ -60,9 +46,6 @@ interface Metrics {
total_bytes: number
}
// 颜色常量
const COLORS = ['#0088FE', '#FF8042', '#00C49F', '#FFBB28', '#FF0000']
export default function DashboardPage() {
const [metrics, setMetrics] = useState<Metrics | null>(null)
const [loading, setLoading] = useState(true)
@ -70,7 +53,7 @@ export default function DashboardPage() {
const { toast } = useToast()
const router = useRouter()
const fetchMetrics = async () => {
const fetchMetrics = useCallback(async () => {
try {
const token = localStorage.getItem("token")
if (!token) {
@ -108,16 +91,13 @@ export default function DashboardPage() {
} finally {
setLoading(false)
}
}
}, [router, toast])
useEffect(() => {
// 立即获取一次数据
fetchMetrics()
// 设置定时刷新
const interval = setInterval(fetchMetrics, 5000)
return () => clearInterval(interval)
}, [])
}, [fetchMetrics])
if (loading) {
return (
@ -176,6 +156,14 @@ export default function DashboardPage() {
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics.total_errors}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{formatBytes(metrics.total_bytes)}</div>
</div>
<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>
</div>
</CardContent>
</Card>
@ -307,145 +295,6 @@ export default function DashboardPage() {
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics?.latency_stats?.min}</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics?.latency_stats?.max}</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics?.avg_response_time}</div>
</div>
</div>
<div className="h-80 mt-6">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={Object.entries(metrics?.latency_stats?.distribution || {}).map(([name, value]) => ({
name,
value,
}))}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="value" name="请求数" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{ name: '客户端错误', value: metrics?.error_stats?.client_errors || 0 },
{ name: '服务器错误', value: metrics?.error_stats?.server_errors || 0 },
]}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{[0, 1].map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={Object.entries(metrics?.error_stats?.types || {}).map(([name, value]) => ({
name,
value,
}))}
layout="vertical"
margin={{ top: 5, right: 30, left: 120, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis type="category" dataKey="name" width={120} />
<Tooltip />
<Legend />
<Bar dataKey="value" name="错误次数" fill={COLORS[1]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics?.current_bandwidth}</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{formatBytes(metrics?.total_bytes || 0)}</div>
</div>
</div>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={Object.entries(metrics?.bandwidth_history || {}).map(([time, value]) => ({
time,
value: parseFloat(value.split(' ')[0]),
unit: value.split(' ')[1],
}))}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis />
<Tooltip formatter={(value, name, props) => [`${value} ${props.payload.unit}`, '带宽']} />
<Legend />
<Line type="monotone" dataKey="value" name="带宽" stroke="#8884d8" />
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

24
web/package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "web",
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
@ -988,6 +989,29 @@
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz",
"integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2"
},
"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-portal": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",

View File

@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",