diff --git a/cmd/maxx/main.go b/cmd/maxx/main.go index c53353fb..186c4f45 100644 --- a/cmd/maxx/main.go +++ b/cmd/maxx/main.go @@ -463,7 +463,7 @@ func main() { requestTracker := core.NewRequestTracker() // Create handlers - proxyHandler := handler.NewProxyHandler(clientAdapter, requestExecutor, cachedSessionRepo, tokenAuthMiddleware) + proxyHandler := handler.NewProxyHandler(clientAdapter, requestExecutor, cachedSessionRepo, settingRepo, tokenAuthMiddleware) proxyHandler.SetRequestTracker(requestTracker) adminHandler := handler.NewAdminHandler(adminService, backupService, logPath) selfServiceHandler := handler.NewSelfServiceHandler(adminService) diff --git a/internal/core/database.go b/internal/core/database.go index f47c15b5..6df0b892 100644 --- a/internal/core/database.go +++ b/internal/core/database.go @@ -452,7 +452,7 @@ func InitializeServerComponents( log.Printf("[Core] Creating handlers") tokenAuthMiddleware := handler.NewTokenAuthMiddleware(repos.CachedAPITokenRepo, repos.SettingRepo) - proxyHandler := handler.NewProxyHandler(clientAdapter, exec, repos.CachedSessionRepo, tokenAuthMiddleware) + proxyHandler := handler.NewProxyHandler(clientAdapter, exec, repos.CachedSessionRepo, repos.SettingRepo, tokenAuthMiddleware) modelsHandler := handler.NewModelsHandler( repos.ResponseModelRepo, repos.CachedProviderRepo, diff --git a/internal/domain/model.go b/internal/domain/model.go index 4184fe4c..8ac190b1 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -688,6 +688,7 @@ const ( SettingKeyAutoSortCodex = "auto_sort_codex" // 是否自动排序 Codex 路由,"true" 或 "false" SettingKeyCodexInstructionsEnabled = "codex_instructions_enabled" // 是否启用 Codex 官方 instructions,"true" 或 "false" SettingKeyPayloadOverrideRules = "payload_override_rules" // 请求 payload 覆盖规则(JSON 数组) + SettingKeyProxyRequestsDisabled = "proxy_requests_disabled" // 是否全局禁用代理请求,"true" 或 "false",默认 "false" SettingKeyEnablePprof = "enable_pprof" // 是否启用 pprof 性能分析,"true" 或 "false",默认 "false" SettingKeyPprofPort = "pprof_port" // pprof 服务端口,默认 6060 SettingKeyPprofPassword = "pprof_password" // pprof 访问密码,为空表示不需要密码 diff --git a/internal/handler/proxy.go b/internal/handler/proxy.go index c9cd4e3c..6bed91bb 100644 --- a/internal/handler/proxy.go +++ b/internal/handler/proxy.go @@ -17,9 +17,13 @@ import ( "github.com/awsl-project/maxx/internal/domain" "github.com/awsl-project/maxx/internal/executor" "github.com/awsl-project/maxx/internal/flow" + "github.com/awsl-project/maxx/internal/repository" "github.com/awsl-project/maxx/internal/repository/cached" + "github.com/awsl-project/maxx/internal/systemsettingcache" ) +const proxyRequestsDisabledMessage = "proxy requests are temporarily disabled by admin" + // RequestTracker interface for tracking active requests type RequestTracker interface { Add() bool @@ -32,6 +36,7 @@ type ProxyHandler struct { clientAdapter *client.Adapter executor *executor.Executor sessionRepo *cached.SessionRepository + settingRepo repository.SystemSettingRepository tokenAuth *TokenAuthMiddleware tracker RequestTracker trackerMu sync.RWMutex @@ -44,12 +49,14 @@ func NewProxyHandler( clientAdapter *client.Adapter, exec *executor.Executor, sessionRepo *cached.SessionRepository, + settingRepo repository.SystemSettingRepository, tokenAuth *TokenAuthMiddleware, ) *ProxyHandler { h := &ProxyHandler{ clientAdapter: clientAdapter, executor: exec, sessionRepo: sessionRepo, + settingRepo: settingRepo, tokenAuth: tokenAuth, engine: flow.NewEngine(), } @@ -103,6 +110,13 @@ func (h *ProxyHandler) ingress(c *flow.Ctx) { return } + if h.isProxyRequestsDisabled() { + log.Printf("[Proxy] Rejecting request because proxy kill switch is enabled: %s %s", r.Method, r.URL.Path) + writeError(w, http.StatusServiceUnavailable, proxyRequestsDisabledMessage) + c.Abort() + return + } + if strings.HasPrefix(r.URL.Path, "/v1/responses") { r.URL.Path = strings.TrimPrefix(r.URL.Path, "/v1") } @@ -303,6 +317,14 @@ func normalizeOpenAIChatCompletionsPayload(body []byte) ([]byte, bool) { return converted, true } +func (h *ProxyHandler) isProxyRequestsDisabled() bool { + return isBooleanSystemSettingEnabled(h.settingRepo, domain.SettingKeyProxyRequestsDisabled) +} + +func isBooleanSystemSettingEnabled(repo repository.SystemSettingRepository, key string) bool { + return systemsettingcache.GetBoolean(repo, key) +} + // Helper functions func writeError(w http.ResponseWriter, status int, message string) { diff --git a/internal/handler/proxy_test.go b/internal/handler/proxy_test.go index 56132035..21f831a0 100644 --- a/internal/handler/proxy_test.go +++ b/internal/handler/proxy_test.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "strconv" @@ -10,8 +11,34 @@ import ( "time" "github.com/awsl-project/maxx/internal/domain" + "github.com/awsl-project/maxx/internal/systemsettingcache" ) +type proxyBooleanSettingRepo struct { + values []string + errs []error + reads int +} + +func (r *proxyBooleanSettingRepo) Get(key string) (string, error) { + r.reads++ + idx := r.reads - 1 + if idx < len(r.errs) && r.errs[idx] != nil { + return "", r.errs[idx] + } + if idx < len(r.values) { + return r.values[idx], nil + } + if len(r.values) > 0 { + return r.values[len(r.values)-1], nil + } + return "", nil +} + +func (r *proxyBooleanSettingRepo) Set(key, value string) error { return nil } +func (r *proxyBooleanSettingRepo) GetAll() ([]*domain.SystemSetting, error) { return nil, nil } +func (r *proxyBooleanSettingRepo) Delete(key string) error { return nil } + func TestWriteError(t *testing.T) { rec := httptest.NewRecorder() writeError(rec, http.StatusBadRequest, "bad request") @@ -123,3 +150,72 @@ func TestWriteStreamErrorPreservesStatusAndRetryAfter(t *testing.T) { t.Fatalf("stream body = %q, want error event", rec.Body.String()) } } + +func TestProxyHandlerRejectsRequestsWhenKillSwitchEnabled(t *testing.T) { + repo := &settingsTestRepo{values: map[string]string{domain.SettingKeyProxyRequestsDisabled: "true"}} + h := NewProxyHandler(nil, nil, nil, repo, nil) + + req := httptest.NewRequest(http.MethodPost, "http://example.test/v1/chat/completions", strings.NewReader(`{"model":"gpt-5.4","messages":[]}`)) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusServiceUnavailable, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), proxyRequestsDisabledMessage) { + t.Fatalf("body = %q, want disabled message", rec.Body.String()) + } +} + +func TestIsBooleanSystemSettingEnabledDefaultsFalse(t *testing.T) { + systemsettingcache.Invalidate(domain.SettingKeyProxyRequestsDisabled) + repo := &settingsTestRepo{} + if isBooleanSystemSettingEnabled(repo, domain.SettingKeyProxyRequestsDisabled) { + t.Fatal("expected missing setting to default to false") + } +} + +func TestIsBooleanSystemSettingEnabledCachesFreshValue(t *testing.T) { + oldTTL := systemsettingcache.BooleanTTL + systemsettingcache.BooleanTTL = time.Hour + defer func() { systemsettingcache.BooleanTTL = oldTTL }() + + key := domain.SettingKeyProxyRequestsDisabled + systemsettingcache.Invalidate(key) + repo := &proxyBooleanSettingRepo{values: []string{"true"}} + + if !isBooleanSystemSettingEnabled(repo, key) { + t.Fatal("expected first read to return true") + } + if !isBooleanSystemSettingEnabled(repo, key) { + t.Fatal("expected cached read to return true") + } + if repo.reads != 1 { + t.Fatalf("reads = %d, want 1", repo.reads) + } +} + +func TestIsBooleanSystemSettingEnabledFallsBackToLastKnownValueOnReadError(t *testing.T) { + oldTTL := systemsettingcache.BooleanTTL + systemsettingcache.BooleanTTL = time.Nanosecond + defer func() { systemsettingcache.BooleanTTL = oldTTL }() + + key := domain.SettingKeyProxyRequestsDisabled + systemsettingcache.Invalidate(key) + repo := &proxyBooleanSettingRepo{ + values: []string{"true"}, + errs: []error{nil, errors.New("db temporarily unavailable")}, + } + + if !isBooleanSystemSettingEnabled(repo, key) { + t.Fatal("expected first read to return true") + } + time.Sleep(time.Millisecond) + if !isBooleanSystemSettingEnabled(repo, key) { + t.Fatal("expected cached true value on refresh error") + } + if repo.reads != 2 { + t.Fatalf("reads = %d, want 2", repo.reads) + } +} diff --git a/internal/service/admin.go b/internal/service/admin.go index 5745e680..062cd087 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -19,6 +19,7 @@ import ( "github.com/awsl-project/maxx/internal/payloadoverride" "github.com/awsl-project/maxx/internal/pricing" "github.com/awsl-project/maxx/internal/repository" + "github.com/awsl-project/maxx/internal/systemsettingcache" "github.com/awsl-project/maxx/internal/version" ) @@ -486,6 +487,7 @@ func (s *AdminService) UpdateSetting(key, value string) error { if err := s.settingRepo.Set(key, value); err != nil { return err } + systemsettingcache.Invalidate(key) if key == domain.SettingKeyPayloadOverrideRules { payloadoverride.InvalidateGlobalSettingsCache() } @@ -507,6 +509,7 @@ func (s *AdminService) DeleteSetting(key string) error { if err := s.settingRepo.Delete(key); err != nil { return err } + systemsettingcache.Invalidate(key) if key == domain.SettingKeyPayloadOverrideRules { payloadoverride.InvalidateGlobalSettingsCache() } diff --git a/internal/service/system_setting_validation.go b/internal/service/system_setting_validation.go index f74ef409..dcc47f79 100644 --- a/internal/service/system_setting_validation.go +++ b/internal/service/system_setting_validation.go @@ -1,6 +1,9 @@ package service import ( + "fmt" + "strings" + "github.com/awsl-project/maxx/internal/domain" "github.com/awsl-project/maxx/internal/payloadoverride" ) @@ -9,7 +12,22 @@ func validateSystemSettingValue(key, value string) error { switch key { case domain.SettingKeyPayloadOverrideRules: return payloadoverride.ValidateRulesJSON(value) + case domain.SettingKeyProxyRequestsDisabled: + return validateBooleanSystemSetting(value) default: return nil } } + +func validateBooleanSystemSetting(value string) error { + if strings.TrimSpace(value) != value { + return fmt.Errorf("%w: boolean setting must not contain surrounding whitespace", domain.ErrInvalidInput) + } + + switch value { + case "true", "false": + return nil + default: + return fmt.Errorf("%w: boolean setting must be \"true\" or \"false\"", domain.ErrInvalidInput) + } +} diff --git a/internal/service/system_setting_validation_test.go b/internal/service/system_setting_validation_test.go index eac8cc15..6606d1b1 100644 --- a/internal/service/system_setting_validation_test.go +++ b/internal/service/system_setting_validation_test.go @@ -3,8 +3,10 @@ package service import ( "errors" "testing" + "time" "github.com/awsl-project/maxx/internal/domain" + "github.com/awsl-project/maxx/internal/systemsettingcache" ) type stubSystemSettingRepo struct { @@ -48,6 +50,87 @@ func TestAdminServiceUpdateSettingRejectsInvalidPayloadOverrideRules(t *testing. } } +func TestAdminServiceUpdateSettingRejectsInvalidProxyRequestsDisabledValue(t *testing.T) { + repo := &stubSystemSettingRepo{} + svc := &AdminService{settingRepo: repo} + + err := svc.UpdateSetting(domain.SettingKeyProxyRequestsDisabled, "maybe") + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("expected invalid input error, got %v", err) + } + if len(repo.values) != 0 { + t.Fatalf("expected invalid setting not to be persisted") + } +} + +func TestAdminServiceUpdateSettingAcceptsValidProxyRequestsDisabledValue(t *testing.T) { + repo := &stubSystemSettingRepo{} + svc := &AdminService{settingRepo: repo} + + err := svc.UpdateSetting(domain.SettingKeyProxyRequestsDisabled, "true") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := repo.values[domain.SettingKeyProxyRequestsDisabled]; got != "true" { + t.Fatalf("stored value = %q, want true", got) + } +} + +func TestAdminServiceUpdateSettingRejectsProxyRequestsDisabledValueWithWhitespace(t *testing.T) { + repo := &stubSystemSettingRepo{} + svc := &AdminService{settingRepo: repo} + + err := svc.UpdateSetting(domain.SettingKeyProxyRequestsDisabled, " true ") + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("expected invalid input error, got %v", err) + } + if len(repo.values) != 0 { + t.Fatalf("expected invalid setting not to be persisted") + } +} + +func TestAdminServiceUpdateSettingInvalidatesProxyBooleanCache(t *testing.T) { + oldTTL := systemsettingcache.BooleanTTL + systemsettingcache.BooleanTTL = time.Hour + defer func() { systemsettingcache.BooleanTTL = oldTTL }() + + repo := &stubSystemSettingRepo{values: map[string]string{domain.SettingKeyProxyRequestsDisabled: "false"}} + svc := &AdminService{settingRepo: repo} + + systemsettingcache.Invalidate(domain.SettingKeyProxyRequestsDisabled) + if systemsettingcache.GetBoolean(repo, domain.SettingKeyProxyRequestsDisabled) { + t.Fatal("expected initial cached value to be false") + } + + if err := svc.UpdateSetting(domain.SettingKeyProxyRequestsDisabled, "true"); err != nil { + t.Fatalf("expected update to succeed, got %v", err) + } + if !systemsettingcache.GetBoolean(repo, domain.SettingKeyProxyRequestsDisabled) { + t.Fatal("expected cache invalidation to expose updated true value") + } +} + +func TestAdminServiceDeleteSettingInvalidatesProxyBooleanCache(t *testing.T) { + oldTTL := systemsettingcache.BooleanTTL + systemsettingcache.BooleanTTL = time.Hour + defer func() { systemsettingcache.BooleanTTL = oldTTL }() + + repo := &stubSystemSettingRepo{values: map[string]string{domain.SettingKeyProxyRequestsDisabled: "true"}} + svc := &AdminService{settingRepo: repo} + + systemsettingcache.Invalidate(domain.SettingKeyProxyRequestsDisabled) + if !systemsettingcache.GetBoolean(repo, domain.SettingKeyProxyRequestsDisabled) { + t.Fatal("expected initial cached value to be true") + } + + if err := svc.DeleteSetting(domain.SettingKeyProxyRequestsDisabled); err != nil { + t.Fatalf("expected delete to succeed, got %v", err) + } + if systemsettingcache.GetBoolean(repo, domain.SettingKeyProxyRequestsDisabled) { + t.Fatal("expected cache invalidation to expose deleted false value") + } +} + func TestBackupServiceImportSystemSettingsSkipsPayloadOverrideRules(t *testing.T) { repo := &stubSystemSettingRepo{ values: map[string]string{}, diff --git a/internal/systemsettingcache/boolean.go b/internal/systemsettingcache/boolean.go new file mode 100644 index 00000000..776f1b69 --- /dev/null +++ b/internal/systemsettingcache/boolean.go @@ -0,0 +1,88 @@ +package systemsettingcache + +import ( + "log" + "strings" + "sync" + "time" + + "github.com/awsl-project/maxx/internal/repository" +) + +// BooleanTTL limits how often hot-path boolean settings re-read the repository. +// Keep it short so admin changes take effect quickly once the cache is invalidated. +var BooleanTTL = time.Second + +type booleanCacheEntry struct { + value bool + fetchedAt time.Time +} + +var ( + booleanMu sync.RWMutex + booleanCache = make(map[string]booleanCacheEntry) +) + +func GetBoolean(repo repository.SystemSettingRepository, key string) bool { + if repo == nil { + return false + } + + now := time.Now() + if value, ok := getFreshBoolean(key, now); ok { + return value + } + + rawValue, err := repo.Get(key) + if err != nil { + if value, ok := getCachedBoolean(key); ok { + log.Printf("[SystemSettingCache] Failed to refresh %s, using cached value: %v", key, err) + return value + } + log.Printf("[SystemSettingCache] Failed to read %s: %v", key, err) + return false + } + + value := normalizeBoolean(rawValue) + storeBoolean(key, value, now) + return value +} + +func Invalidate(key string) { + booleanMu.Lock() + delete(booleanCache, key) + booleanMu.Unlock() +} + +func getFreshBoolean(key string, now time.Time) (bool, bool) { + booleanMu.RLock() + entry, ok := booleanCache[key] + booleanMu.RUnlock() + if !ok { + return false, false + } + if BooleanTTL > 0 && now.Sub(entry.fetchedAt) <= BooleanTTL { + return entry.value, true + } + return false, false +} + +func getCachedBoolean(key string) (bool, bool) { + booleanMu.RLock() + entry, ok := booleanCache[key] + booleanMu.RUnlock() + if !ok { + return false, false + } + return entry.value, true +} + +func storeBoolean(key string, value bool, fetchedAt time.Time) { + booleanMu.Lock() + booleanCache[key] = booleanCacheEntry{value: value, fetchedAt: fetchedAt} + booleanMu.Unlock() +} + +func normalizeBoolean(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), "true") +} diff --git a/internal/systemsettingcache/boolean_test.go b/internal/systemsettingcache/boolean_test.go new file mode 100644 index 00000000..f0b33ca4 --- /dev/null +++ b/internal/systemsettingcache/boolean_test.go @@ -0,0 +1,78 @@ +package systemsettingcache + +import ( + "errors" + "testing" + "time" + + "github.com/awsl-project/maxx/internal/domain" +) + +type stubRepo struct { + values []string + errs []error + reads int +} + +func (r *stubRepo) Get(key string) (string, error) { + r.reads++ + idx := r.reads - 1 + if idx < len(r.errs) && r.errs[idx] != nil { + return "", r.errs[idx] + } + if idx < len(r.values) { + return r.values[idx], nil + } + if len(r.values) > 0 { + return r.values[len(r.values)-1], nil + } + return "", nil +} + +func (r *stubRepo) Set(key, value string) error { return nil } +func (r *stubRepo) GetAll() ([]*domain.SystemSetting, error) { return nil, nil } +func (r *stubRepo) Delete(key string) error { return nil } + +func TestGetBooleanCachesFreshValue(t *testing.T) { + oldTTL := BooleanTTL + BooleanTTL = time.Hour + defer func() { BooleanTTL = oldTTL }() + + key := "proxy_requests_disabled" + Invalidate(key) + + repo := &stubRepo{values: []string{"true"}} + if !GetBoolean(repo, key) { + t.Fatal("expected first read to return true") + } + if !GetBoolean(repo, key) { + t.Fatal("expected cached read to return true") + } + if repo.reads != 1 { + t.Fatalf("reads = %d, want 1", repo.reads) + } +} + +func TestGetBooleanFallsBackToLastKnownValueOnRefreshError(t *testing.T) { + oldTTL := BooleanTTL + BooleanTTL = time.Nanosecond + defer func() { BooleanTTL = oldTTL }() + + key := "proxy_requests_disabled" + Invalidate(key) + + repo := &stubRepo{ + values: []string{"true"}, + errs: []error{nil, errors.New("db temporarily unavailable")}, + } + if !GetBoolean(repo, key) { + t.Fatal("expected first read to return true") + } + time.Sleep(time.Millisecond) + if !GetBoolean(repo, key) { + t.Fatal("expected stale cached true value on refresh error") + } + if repo.reads != 2 { + t.Fatalf("reads = %d, want 2", repo.reads) + } +} diff --git a/tests/e2e/proxy_setup_test.go b/tests/e2e/proxy_setup_test.go index 420e4aa5..4e46d9c5 100644 --- a/tests/e2e/proxy_setup_test.go +++ b/tests/e2e/proxy_setup_test.go @@ -184,7 +184,7 @@ func NewProxyTestEnv(t *testing.T) *ProxyTestEnv { tokenAuthMiddleware := handler.NewTokenAuthMiddleware(cachedAPITokenRepo, settingRepo) // Create proxy handler - proxyHandler := handler.NewProxyHandler(clientAdapter, requestExecutor, cachedSessionRepo, tokenAuthMiddleware) + proxyHandler := handler.NewProxyHandler(clientAdapter, requestExecutor, cachedSessionRepo, settingRepo, tokenAuthMiddleware) // Create admin and auth handlers adminHandler := handler.NewAdminHandler(adminService, backupService, "") diff --git a/web/src/components/settings/proxy-kill-switch-card.tsx b/web/src/components/settings/proxy-kill-switch-card.tsx new file mode 100644 index 00000000..10e64cd4 --- /dev/null +++ b/web/src/components/settings/proxy-kill-switch-card.tsx @@ -0,0 +1,70 @@ +import { AlertTriangle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Card, CardContent, CardHeader, CardTitle, Switch } from '@/components/ui'; +import { useSettings, useUpdateSetting } from '@/hooks/queries'; +import { cn } from '@/lib/utils'; + +interface ProxyKillSwitchCardProps { + className?: string; +} + +export function ProxyKillSwitchCard({ className }: ProxyKillSwitchCardProps) { + const { data: settings, isLoading } = useSettings(); + const updateSetting = useUpdateSetting(); + const { t } = useTranslation(); + + const proxyRequestsDisabled = + (settings?.proxy_requests_disabled ?? '').trim().toLowerCase() === 'true'; + + const handleToggle = async (checked: boolean) => { + await updateSetting.mutateAsync({ + key: 'proxy_requests_disabled', + value: checked ? 'true' : 'false', + }); + }; + + if (isLoading) return null; + + return ( + + + + + {t('settings.proxyKillSwitch')} + + + +
+
+
+ {t('settings.enableProxyKillSwitch')} +
+

+ {t('settings.proxyKillSwitchDesc')} +

+
+ +
+ + {proxyRequestsDisabled && ( +
+ +

+ {t('settings.proxyKillSwitchEnabledHint')} +

+
+ )} +
+
+ ); +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index b19dd0e9..0627724a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -970,6 +970,10 @@ "forceProjectBindingDesc": "When enabled, new sessions must select a project before executing requests", "waitTimeout": "Wait Timeout (seconds)", "waitTimeoutRange": "5 - 300 seconds", + "proxyKillSwitch": "Request Kill Switch", + "enableProxyKillSwitch": "Disable all proxy requests", + "proxyKillSwitchDesc": "When enabled, every proxy request is rejected immediately with 503. Use it as an emergency stop during maintenance windows, upstream incidents, or other firebreak moments.", + "proxyKillSwitchEnabledHint": "The kill switch is currently on. No user can start a new proxy request until you turn it off.", "antigravityModelMapping": "Antigravity Global Model Mapping", "clearAll": "Clear All", "clearAllMappings": "Clear All Model Mappings", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index b8982378..c7b760e2 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -969,6 +969,10 @@ "forceProjectBindingDesc": "开启后,新会话必须选择项目才能继续执行请求", "waitTimeout": "等待超时(秒)", "waitTimeoutRange": "5 - 300 秒", + "proxyKillSwitch": "请求总闸", + "enableProxyKillSwitch": "启用全局请求熔断", + "proxyKillSwitchDesc": "开启后,所有代理请求会立即被拒绝并返回 503。适合在维护窗口、上游事故或紧急止血时一键拉闸。", + "proxyKillSwitchEnabledHint": "当前总闸已拉下,任何用户都无法发起新的代理请求,直到你关闭此开关。", "antigravityModelMapping": "Antigravity 全局模型映射", "clearAll": "清空全部", "clearAllMappings": "清空全部模型映射", diff --git a/web/src/pages/client-routes/index.tsx b/web/src/pages/client-routes/index.tsx index ee3b08e1..a744161f 100644 --- a/web/src/pages/client-routes/index.tsx +++ b/web/src/pages/client-routes/index.tsx @@ -16,6 +16,7 @@ import { ChevronLeft, ChevronRight, } from 'lucide-react'; +import { ProxyKillSwitchCard } from '@/components/settings/proxy-kill-switch-card'; import { ClientIcon, getClientName } from '@/components/icons/client-icons'; import { PageHeader } from '@/components/layout/page-header'; import type { ClientType } from '@/lib/transport'; @@ -26,6 +27,7 @@ import { useProjects, useUpdateProject, useRoutes, useProviders, routeKeys } fro import { useTransport } from '@/lib/transport/context'; import { useQueryClient } from '@tanstack/react-query'; import { cn } from '@/lib/utils'; +import { useAuth } from '@/lib/auth-context'; const SCROLL_STEP = 200; @@ -134,7 +136,9 @@ function ProjectTabBar({ export function ClientRoutesPage() { const { t } = useTranslation(); + const { user } = useAuth(); const { clientType } = useParams<{ clientType: string }>(); + const isAdmin = user?.role === 'admin'; const activeClientType = (clientType as ClientType) || 'claude'; const [searchQuery, setSearchQuery] = useState(''); const [selectedProjectId, setSelectedProjectId] = useState('0'); // '0' = Global @@ -256,6 +260,14 @@ export function ClientRoutesPage() { } /> + {isAdmin && ( +
+
+ +
+
+ )} + {/* Tabs for Global / Projects */} + @@ -940,6 +942,10 @@ function ForceProjectSection() { ); } +function ProxyKillSwitchSection() { + return ; +} + function PayloadOverrideSection() { const { data: settings, isLoading } = useSettings(); const updateSetting = useUpdateSetting();