Skip to content

Commit 4c2e95b

Browse files
authored
feat: 优化 Cooldown 机制和落雪视觉效果 (#136)
* feat: 优化 Cooldown 机制和前端交互体验 - 修复网络错误未正确触发 Cooldown 的问题 - 将默认 Cooldown 时间从 60s 改为 5s 起步 - 将 Cooldown 策略从分钟级改为秒级,支持更精细的控制 - Provider Row 点击 Unfreeze 按钮可立即解冻 - 手动解冻时清空失败计数器 - 修复倒计时到期后前端未正确恢复的问题 - 拖拽时全局设置 cursor 为 grab - 优化倒计时使用递归 setTimeout 替代 setInterval * feat: 优化 Cooldown 落雪效果视觉样式 - 雪花使用 radial-gradient 实现半透明柔和边缘效果 - Light 模式使用深灰色雪花,Dark 模式使用白色雪花 - Cooldown 行背景改为透明,保留可见边框 - 倒计时框背景改为透明 - Icon 框使用实色背景,Cooldown 时隐藏字母只显示雪花图标 - 雪花动画置于所有元素后面 (z-0) - 增强 Cooldown 状态下 Row 的 hover 效果
1 parent e498593 commit 4c2e95b

File tree

30 files changed

+712
-436
lines changed

30 files changed

+712
-436
lines changed

internal/cooldown/failure_tracker.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,14 @@ func (ft *FailureTracker) GetFailureCount(providerID uint64, clientType string,
9191
}
9292

9393
// ResetFailures resets all failure counts for a provider+clientType
94+
// If clientType is empty, resets ALL failure counts for the provider
9495
func (ft *FailureTracker) ResetFailures(providerID uint64, clientType string) {
9596
// Clear failure counts for all reasons for this provider+clientType
9697
keysToDelete := []FailureKey{}
9798
for key := range ft.failureCounts {
98-
if key.ProviderID == providerID && key.ClientType == clientType {
99+
// If clientType is empty, match all clientTypes for this provider
100+
// Otherwise, only match the specific clientType
101+
if key.ProviderID == providerID && (clientType == "" || key.ClientType == clientType) {
99102
keysToDelete = append(keysToDelete, key)
100103
}
101104
}

internal/cooldown/manager.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,9 @@ func (m *Manager) RecordFailure(providerID uint64, clientType string, reason Coo
109109
// Get policy for this reason
110110
policy, ok := m.policies[reason]
111111
if !ok {
112-
// Fallback to fixed 1-minute cooldown if no policy found
113-
policy = &FixedDurationPolicy{Duration: 1 * time.Minute}
114-
log.Printf("[Cooldown] Warning: No policy found for reason=%s, using default 1-minute cooldown", reason)
112+
// Fallback to fixed 5-second cooldown if no policy found
113+
policy = &FixedDurationPolicy{Duration: 5 * time.Second}
114+
log.Printf("[Cooldown] Warning: No policy found for reason=%s, using default 5-second cooldown", reason)
115115
}
116116

117117
// Calculate cooldown duration

internal/cooldown/policy.go

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,42 +20,42 @@ func (p *FixedDurationPolicy) CalculateCooldown(failureCount int) time.Duration
2020
}
2121

2222
// LinearIncrementalPolicy increases cooldown linearly with each failure
23-
// Formula: baseMinutes * failureCount
23+
// Formula: baseSeconds * failureCount
2424
type LinearIncrementalPolicy struct {
25-
BaseMinutes int
26-
MaxMinutes int // Optional cap, 0 means no limit
25+
BaseSeconds int
26+
MaxSeconds int // Optional cap, 0 means no limit
2727
}
2828

2929
func (p *LinearIncrementalPolicy) CalculateCooldown(failureCount int) time.Duration {
30-
minutes := p.BaseMinutes * failureCount
31-
if p.MaxMinutes > 0 && minutes > p.MaxMinutes {
32-
minutes = p.MaxMinutes
30+
seconds := p.BaseSeconds * failureCount
31+
if p.MaxSeconds > 0 && seconds > p.MaxSeconds {
32+
seconds = p.MaxSeconds
3333
}
34-
return time.Duration(minutes) * time.Minute
34+
return time.Duration(seconds) * time.Second
3535
}
3636

3737
// ExponentialBackoffPolicy increases cooldown exponentially with each failure
38-
// Formula: baseMinutes * (2 ^ (failureCount - 1))
38+
// Formula: baseSeconds * (2 ^ (failureCount - 1))
3939
type ExponentialBackoffPolicy struct {
40-
BaseMinutes int
41-
MaxMinutes int // Optional cap, 0 means no limit
40+
BaseSeconds int
41+
MaxSeconds int // Optional cap, 0 means no limit
4242
}
4343

4444
func (p *ExponentialBackoffPolicy) CalculateCooldown(failureCount int) time.Duration {
4545
if failureCount == 0 {
4646
return 0
4747
}
4848

49-
minutes := p.BaseMinutes
49+
seconds := p.BaseSeconds
5050
for i := 1; i < failureCount; i++ {
51-
minutes *= 2
52-
if p.MaxMinutes > 0 && minutes > p.MaxMinutes {
53-
minutes = p.MaxMinutes
51+
seconds *= 2
52+
if p.MaxSeconds > 0 && seconds > p.MaxSeconds {
53+
seconds = p.MaxSeconds
5454
break
5555
}
5656
}
5757

58-
return time.Duration(minutes) * time.Minute
58+
return time.Duration(seconds) * time.Second
5959
}
6060

6161
// CooldownReason represents the reason for cooldown
@@ -75,32 +75,32 @@ const (
7575
// those times will be used directly instead of these policies
7676
func DefaultPolicies() map[CooldownReason]CooldownPolicy {
7777
return map[CooldownReason]CooldownPolicy{
78-
// Server errors (5xx): linear increment (1min, 2min, 3min, ... max 10min)
78+
// Server errors (5xx): linear increment (5s, 10s, 15s, ... max 10min)
7979
ReasonServerError: &LinearIncrementalPolicy{
80-
BaseMinutes: 1,
81-
MaxMinutes: 10,
80+
BaseSeconds: 5,
81+
MaxSeconds: 600, // 10 minutes
8282
},
83-
// Network errors: exponential backoff (1min, 2min, 4min, 8min, ... max 30min)
83+
// Network errors: exponential backoff (5s, 10s, 20s, 40s, ... max 30min)
8484
ReasonNetworkError: &ExponentialBackoffPolicy{
85-
BaseMinutes: 1,
86-
MaxMinutes: 30,
85+
BaseSeconds: 5,
86+
MaxSeconds: 1800, // 30 minutes
8787
},
8888
// Quota exhausted: fixed 1 hour (only used as fallback when API doesn't return reset time)
8989
ReasonQuotaExhausted: &FixedDurationPolicy{
9090
Duration: 1 * time.Hour,
9191
},
92-
// Rate limit: fixed 1 minute (only used as fallback when API doesn't return Retry-After)
92+
// Rate limit: fixed 5 seconds (only used as fallback when API doesn't return Retry-After)
9393
ReasonRateLimit: &FixedDurationPolicy{
94-
Duration: 1 * time.Minute,
94+
Duration: 5 * time.Second,
9595
},
96-
// Concurrent limit: fixed 10 seconds (only used as fallback)
96+
// Concurrent limit: fixed 5 seconds (only used as fallback)
9797
ReasonConcurrentLimit: &FixedDurationPolicy{
98-
Duration: 10 * time.Second,
98+
Duration: 5 * time.Second,
9999
},
100-
// Unknown error: linear increment (1min, 2min, 3min, ... max 5min)
100+
// Unknown error: linear increment (5s, 10s, 15s, ... max 5min)
101101
ReasonUnknown: &LinearIncrementalPolicy{
102-
BaseMinutes: 1,
103-
MaxMinutes: 5,
102+
BaseSeconds: 5,
103+
MaxSeconds: 300, // 5 minutes
104104
},
105105
}
106106
}

internal/executor/executor.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,24 @@ func (e *Executor) Execute(ctx context.Context, w http.ResponseWriter, req *http
514514
e.broadcaster.BroadcastProxyRequest(proxyReq)
515515
}
516516

517+
// Handle cooldown BEFORE checking context cancellation
518+
// This ensures network errors trigger cooldown even if context is cancelled
519+
proxyErr, ok := err.(*domain.ProxyError)
520+
if ok {
521+
log.Printf("[Executor] ProxyError - IsNetworkError: %v, IsServerError: %v, Retryable: %v, Provider: %d",
522+
proxyErr.IsNetworkError, proxyErr.IsServerError, proxyErr.Retryable, matchedRoute.Provider.ID)
523+
// Handle cooldown (unified cooldown logic for all providers)
524+
e.handleCooldown(attemptCtx, proxyErr, matchedRoute.Provider)
525+
// Broadcast cooldown update event to frontend
526+
if e.broadcaster != nil {
527+
e.broadcaster.BroadcastMessage("cooldown_update", map[string]interface{}{
528+
"providerID": matchedRoute.Provider.ID,
529+
})
530+
}
531+
} else {
532+
log.Printf("[Executor] Error is not ProxyError, type: %T, error: %v", err, err)
533+
}
534+
517535
// Check if it's a context cancellation (client disconnect)
518536
if ctx.Err() != nil {
519537
// Set final status before returning to ensure it's persisted
@@ -529,15 +547,11 @@ func (e *Executor) Execute(ctx context.Context, w http.ResponseWriter, req *http
529547
return ctx.Err()
530548
}
531549

532-
// Check if retryable
533-
proxyErr, ok := err.(*domain.ProxyError)
550+
// Check if retryable (proxyErr already checked above)
534551
if !ok {
535552
break // Move to next route
536553
}
537554

538-
// Handle cooldown (unified cooldown logic for all providers)
539-
e.handleCooldown(attemptCtx, proxyErr, matchedRoute.Provider)
540-
541555
if !proxyErr.Retryable {
542556
break // Move to next route
543557
}
@@ -665,9 +679,14 @@ func (e *Executor) handleCooldown(ctx context.Context, proxyErr *domain.ProxyErr
665679
if proxyErr.RateLimitInfo != nil && proxyErr.RateLimitInfo.ClientType != "" {
666680
clientType = proxyErr.RateLimitInfo.ClientType
667681
}
668-
// Fallback to current request's clientType if not specified
682+
// Fallback to original client type (before format conversion) if not specified
669683
if clientType == "" {
670-
clientType = string(ctxutil.GetClientType(ctx))
684+
// Prefer original client type over converted type
685+
if origCT := ctxutil.GetOriginalClientType(ctx); origCT != "" {
686+
clientType = string(origCT)
687+
} else {
688+
clientType = string(ctxutil.GetClientType(ctx))
689+
}
671690
}
672691

673692
// Determine cooldown reason and explicit time

internal/handler/admin.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,14 +650,20 @@ func (h *AdminHandler) handleRoutingStrategies(w http.ResponseWriter, r *http.Re
650650
}
651651

652652
// ProxyRequest handlers
653-
// Routes: /admin/requests, /admin/requests/count, /admin/requests/{id}, /admin/requests/{id}/attempts
653+
// Routes: /admin/requests, /admin/requests/count, /admin/requests/active, /admin/requests/{id}, /admin/requests/{id}/attempts
654654
func (h *AdminHandler) handleProxyRequests(w http.ResponseWriter, r *http.Request, id uint64, parts []string) {
655655
// Check for count endpoint: /admin/requests/count
656656
if len(parts) > 2 && parts[2] == "count" {
657657
h.handleProxyRequestsCount(w, r)
658658
return
659659
}
660660

661+
// Check for active endpoint: /admin/requests/active
662+
if len(parts) > 2 && parts[2] == "active" {
663+
h.handleActiveProxyRequests(w, r)
664+
return
665+
}
666+
661667
// Check for sub-resource: /admin/requests/{id}/attempts
662668
if len(parts) > 3 && parts[3] == "attempts" && id > 0 {
663669
h.handleProxyUpstreamAttempts(w, r, id)
@@ -712,6 +718,21 @@ func (h *AdminHandler) handleProxyRequestsCount(w http.ResponseWriter, r *http.R
712718
writeJSON(w, http.StatusOK, count)
713719
}
714720

721+
// ActiveProxyRequests handler - returns all requests with PENDING or IN_PROGRESS status
722+
func (h *AdminHandler) handleActiveProxyRequests(w http.ResponseWriter, r *http.Request) {
723+
if r.Method != http.MethodGet {
724+
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
725+
return
726+
}
727+
728+
requests, err := h.svc.GetActiveProxyRequests()
729+
if err != nil {
730+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
731+
return
732+
}
733+
writeJSON(w, http.StatusOK, requests)
734+
}
735+
715736
// ProxyUpstreamAttempt handlers
716737
func (h *AdminHandler) handleProxyUpstreamAttempts(w http.ResponseWriter, r *http.Request, proxyRequestID uint64) {
717738
if r.Method != http.MethodGet {

internal/repository/interfaces.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ type ProxyRequestRepository interface {
6868
// before: 获取 id < before 的记录 (向后翻页)
6969
// after: 获取 id > after 的记录 (向前翻页/获取新数据)
7070
ListCursor(limit int, before, after uint64) ([]*domain.ProxyRequest, error)
71+
// ListActive 获取所有活跃请求 (PENDING 或 IN_PROGRESS 状态)
72+
ListActive() ([]*domain.ProxyRequest, error)
7173
Count() (int64, error)
7274
// UpdateProjectIDBySessionID 批量更新指定 sessionID 的所有请求的 projectID
7375
UpdateProjectIDBySessionID(sessionID string, projectID uint64) (int64, error)

internal/repository/sqlite/failure_count_repository.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ func (r *FailureCountRepository) Delete(providerID uint64, clientType string, re
7777
}
7878

7979
func (r *FailureCountRepository) DeleteAll(providerID uint64, clientType string) error {
80+
// If clientType is empty, delete ALL failure counts for this provider
81+
if clientType == "" {
82+
return r.db.gorm.Where("provider_id = ?", providerID).Delete(&FailureCount{}).Error
83+
}
84+
// Otherwise, delete only for the specific clientType
8085
return r.db.gorm.Where("provider_id = ? AND client_type = ?", providerID, clientType).Delete(&FailureCount{}).Error
8186
}
8287

internal/repository/sqlite/proxy_request.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ func (r *ProxyRequestRepository) ListCursor(limit int, before, after uint64) ([]
9393
return r.toDomainList(models), nil
9494
}
9595

96+
// ListActive 获取所有活跃请求 (PENDING 或 IN_PROGRESS 状态)
97+
func (r *ProxyRequestRepository) ListActive() ([]*domain.ProxyRequest, error) {
98+
var models []ProxyRequest
99+
if err := r.db.gorm.Model(&ProxyRequest{}).
100+
Select("id, created_at, updated_at, instance_id, request_id, session_id, client_type, request_model, response_model, start_time, end_time, duration_ms, is_stream, status, status_code, error, proxy_upstream_attempt_count, final_proxy_upstream_attempt_id, route_id, provider_id, project_id, input_token_count, output_token_count, cache_read_count, cache_write_count, cache_5m_write_count, cache_1h_write_count, cost, api_token_id").
101+
Where("status IN ?", []string{"PENDING", "IN_PROGRESS"}).
102+
Order("id DESC").
103+
Find(&models).Error; err != nil {
104+
return nil, err
105+
}
106+
return r.toDomainList(models), nil
107+
}
108+
96109
func (r *ProxyRequestRepository) Count() (int64, error) {
97110
return atomic.LoadInt64(&r.count), nil
98111
}

internal/service/admin.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@ func (s *AdminService) GetProxyRequest(id uint64) (*domain.ProxyRequest, error)
392392
return s.proxyRequestRepo.GetByID(id)
393393
}
394394

395+
func (s *AdminService) GetActiveProxyRequests() ([]*domain.ProxyRequest, error) {
396+
return s.proxyRequestRepo.ListActive()
397+
}
398+
395399
func (s *AdminService) GetProxyUpstreamAttempts(proxyRequestID uint64) ([]*domain.ProxyUpstreamAttempt, error) {
396400
return s.attemptRepo.ListByProxyRequestID(proxyRequestID)
397401
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useState, useEffect } from 'react';
2+
import { useQueryClient } from '@tanstack/react-query';
3+
import type { Cooldown } from '@/lib/transport';
4+
5+
interface CooldownTimerProps {
6+
cooldown: Cooldown;
7+
className?: string;
8+
}
9+
10+
/**
11+
* 实时倒计时组件,每秒更新显示
12+
* 过期时自动触发 cooldowns 刷新
13+
*/
14+
export function CooldownTimer({ cooldown, className }: CooldownTimerProps) {
15+
const queryClient = useQueryClient();
16+
const [remainingSeconds, setRemainingSeconds] = useState(() => calculateRemaining(cooldown));
17+
18+
useEffect(() => {
19+
// 每秒更新一次
20+
const interval = setInterval(() => {
21+
const remaining = calculateRemaining(cooldown);
22+
setRemainingSeconds(remaining);
23+
24+
// 过期时刷新 cooldowns
25+
if (remaining <= 0) {
26+
queryClient.invalidateQueries({ queryKey: ['cooldowns'] });
27+
clearInterval(interval);
28+
}
29+
}, 1000);
30+
31+
return () => clearInterval(interval);
32+
}, [cooldown, queryClient]);
33+
34+
// 已过期,不显示
35+
if (remainingSeconds <= 0) {
36+
return null;
37+
}
38+
39+
return <span className={className}>{formatSeconds(remainingSeconds)}</span>;
40+
}
41+
42+
function calculateRemaining(cooldown: Cooldown): number {
43+
const untilTime =
44+
cooldown.untilTime || ((cooldown as unknown as Record<string, unknown>).until as string);
45+
if (!untilTime) return 0;
46+
47+
const until = new Date(untilTime).getTime();
48+
const now = Date.now();
49+
return Math.max(0, Math.floor((until - now) / 1000));
50+
}
51+
52+
function formatSeconds(seconds: number): string {
53+
const hours = Math.floor(seconds / 3600);
54+
const minutes = Math.floor((seconds % 3600) / 60);
55+
const secs = seconds % 60;
56+
57+
if (hours > 0) {
58+
return `${String(hours).padStart(2, '0')}h ${String(minutes).padStart(2, '0')}m ${String(secs).padStart(2, '0')}s`;
59+
} else if (minutes > 0) {
60+
return `${String(minutes).padStart(2, '0')}m ${String(secs).padStart(2, '0')}s`;
61+
} else {
62+
return `${String(secs).padStart(2, '0')}s`;
63+
}
64+
}

0 commit comments

Comments
 (0)