Feat/auto failover (#367)

* feat(db): add circuit breaker config table and provider proxy target APIs

Add database support for auto-failover feature:

- Add circuit_breaker_config table for storing failover thresholds
- Add get/update_circuit_breaker_config methods in proxy DAO
- Add reset_provider_health method for manual recovery
- Add set_proxy_target and get_proxy_targets methods in providers DAO
  for managing multi-provider failover configuration

* feat(proxy): implement circuit breaker and provider router for auto-failover

Add core failover logic:

- CircuitBreaker: Tracks provider health with three states:
  - Closed: Normal operation, requests pass through
  - Open: Circuit broken after consecutive failures, skip provider
  - HalfOpen: Testing recovery with limited requests
- ProviderRouter: Routes requests across multiple providers with:
  - Health tracking and automatic failover
  - Configurable failure/success thresholds
  - Auto-disable proxy target after reaching failure threshold
  - Support for manual circuit breaker reset
- Export new types in proxy module

* feat(proxy): add failover Tauri commands and integrate with forwarder

Expose failover functionality to frontend:

- Add Tauri commands: get_proxy_targets, set_proxy_target,
  get_provider_health, reset_circuit_breaker,
  get/update_circuit_breaker_config, get_circuit_breaker_stats
- Register all new commands in lib.rs invoke handler
- Update forwarder with improved error handling and logging
- Integrate ProviderRouter with proxy server startup
- Add provider health tracking in request handlers

* feat(frontend): add failover API layer and TanStack Query hooks

Add frontend data layer for failover management:

- Add failover.ts API: Tauri invoke wrappers for all failover commands
- Add failover.ts query hooks: TanStack Query mutations and queries
  - useProxyTargets, useProviderHealth queries
  - useSetProxyTarget, useResetCircuitBreaker mutations
  - useCircuitBreakerConfig query and mutation
- Update queries.ts with provider health query key
- Update mutations.ts to invalidate health on provider changes
- Add CircuitBreakerConfig and ProviderHealth types

* feat(ui): add auto-failover configuration UI and provider health display

Add comprehensive UI for failover management:

Components:
- ProviderHealthBadge: Display provider health status with color coding
- CircuitBreakerConfigPanel: Configure failure/success thresholds,
  timeout duration, and error rate limits
- AutoFailoverConfigPanel: Manage proxy targets with drag-and-drop
  priority ordering and individual enable/disable controls
- ProxyPanel: Integrate failover tabs for unified proxy management

Provider enhancements:
- ProviderCard: Show health badge and proxy target indicator
- ProviderActions: Add "Set as Proxy Target" action
- EditProviderDialog: Add is_proxy_target toggle
- ProviderList: Support proxy target filtering mode

Other:
- Update App.tsx routing for settings integration
- Update useProviderActions hook with proxy target mutation
- Fix ProviderList tests for updated component API

* fix(usage): stabilize date range to prevent infinite re-renders

* feat(backend): add tool version check command

Add get_tool_versions command to check local and latest versions of
Claude, Codex, and Gemini CLI tools:

- Detect local installed versions via command line execution
- Fetch latest versions from npm registry (Claude, Gemini)
  and GitHub releases API (Codex)
- Return comprehensive version info including error details
  for uninstalled tools
- Register command in Tauri invoke handler

* style(ui): format accordion component code style

Apply consistent code formatting to accordion component:
- Convert double quotes to semicolons at line endings
- Adjust indentation to 2-space standard
- Align with project code style conventions

* refactor(providers): update provider card styling to use theme tokens

Replace hardcoded color classes with semantic design tokens:
- Use bg-card, border-border, text-card-foreground instead of glass-card
- Replace gray/white color literals with muted/foreground tokens
- Change proxy target indicator color from purple to green
- Improve hover states with border-border-active
- Ensure consistent dark mode support via CSS variables

* refactor(proxy): simplify auto-failover config panel structure

Restructure AutoFailoverConfigPanel for better integration:
- Remove internal Card wrapper and expansion toggle (now handled by parent)
- Extract enabled state to props for external control
- Simplify loading state display
- Clean up redundant CardHeader/CardContent wrappers
- ProxyPanel: reduce complexity by delegating to parent components

* feat(settings): enhance settings page with accordion layout and tool versions

Major settings page improvements:

AboutSection:
- Add local tool version detection (Claude, Codex, Gemini)
- Display installed vs latest version comparison with visual indicators
- Show update availability badges and environment check cards

SettingsPage:
- Reorganize advanced settings into collapsible accordion sections
- Add proxy control panel with inline status toggle
- Integrate auto-failover configuration with accordion UI
- Add database and cost calculation config sections

DirectorySettings & WindowSettings:
- Minor styling adjustments for consistency

settings.ts API:
- Add getToolVersions() wrapper for new backend command

* refactor(usage): restructure usage dashboard components

Comprehensive usage statistics panel refactoring:

UsageDashboard:
- Reorganize layout with improved section headers
- Add better loading states and empty state handling

ModelStatsTable & ProviderStatsTable:
- Minor styling updates for consistency

ModelTestConfigPanel & PricingConfigPanel:
- Simplify component structure
- Remove redundant Card wrappers
- Improve form field organization

RequestLogTable:
- Enhance table layout with better column sizing
- Improve pagination controls

UsageSummaryCards:
- Update card styling with semantic tokens
- Better responsive grid layout

UsageTrendChart:
- Refine chart container styling
- Improve legend and tooltip display

* chore(deps): add accordion and animation dependencies

Package updates:
- Add @radix-ui/react-accordion for collapsible sections
- Add cmdk for command palette support
- Add framer-motion for enhanced animations

Tailwind config:
- Add accordion-up/accordion-down animations
- Update darkMode config to support both selector and class
- Reorganize color and keyframe definitions for clarity

* style(app): update header and app switcher styling

App.tsx:
- Replace glass-header with explicit bg-background/80 backdrop-blur
- Update navigation button container to use bg-muted

AppSwitcher:
- Replace hardcoded gray colors with semantic muted/foreground tokens
- Ensure consistent dark mode support via CSS variables
- Add group class for better hover state transitions
This commit is contained in:
YoVinchen
2025-12-08 21:14:06 +08:00
committed by GitHub
parent 1fb2d5ed44
commit 41267135f5
52 changed files with 3916 additions and 1295 deletions
+156 -134
View File
@@ -65,123 +65,151 @@ export function RequestLogTable() {
return (
<div className="space-y-4">
{/* 筛选栏 */}
<div className="flex flex-wrap items-center gap-2 rounded-md bg-card/60 p-3 shadow-sm">
<Select
value={tempFilters.appType || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
appType: v === "all" ? undefined : v,
})
}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("usage.endpoint", "端点")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-3">
<Select
value={tempFilters.appType || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
appType: v === "all" ? undefined : v,
})
}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.endpoint", "端点")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部端点")}</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
</SelectContent>
</Select>
<Select
value={tempFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
statusCode: v === "all" ? undefined : parseInt(v),
})
}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("usage.status", "状态码")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="400">400</SelectItem>
<SelectItem value="401">401</SelectItem>
<SelectItem value="429">429</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
<Select
value={tempFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
statusCode: v === "all" ? undefined : parseInt(v),
})
}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.status", "状态码")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部状态")}</SelectItem>
<SelectItem value="200">200 OK</SelectItem>
<SelectItem value="400">400 Bad Request</SelectItem>
<SelectItem value="401">401 Unauthorized</SelectItem>
<SelectItem value="429">429 Rate Limit</SelectItem>
<SelectItem value="500">500 Server Error</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t("usage.provider", "供应商名称")}
className="w-[140px]"
value={tempFilters.providerName || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
providerName: e.target.value || undefined,
})
}
/>
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("usage.provider", "搜索供应商...")}
className="pl-9 bg-background"
value={tempFilters.providerName || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
providerName: e.target.value || undefined,
})
}
/>
</div>
<Input
placeholder={t("usage.model", "搜索模型...")}
className="w-[180px] bg-background"
value={tempFilters.model || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
model: e.target.value || undefined,
})
}
/>
</div>
</div>
<Input
placeholder={t("usage.model", "模型名称")}
className="w-[140px]"
value={tempFilters.model || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
model: e.target.value || undefined,
})
}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="whitespace-nowrap">:</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
tempFilters.startDate
? new Date(tempFilters.startDate * 1000)
.toISOString()
.slice(0, 16)
: ""
}
onChange={(e) =>
setTempFilters({
...tempFilters,
startDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
<span>-</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
tempFilters.endDate
? new Date(tempFilters.endDate * 1000)
.toISOString()
.slice(0, 16)
: ""
}
onChange={(e) =>
setTempFilters({
...tempFilters,
endDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
</div>
<Input
type="datetime-local"
className="w-[180px]"
value={
tempFilters.startDate
? new Date(tempFilters.startDate * 1000)
.toISOString()
.slice(0, 16)
: ""
}
onChange={(e) =>
setTempFilters({
...tempFilters,
startDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
<Input
type="datetime-local"
className="w-[180px]"
value={
tempFilters.endDate
? new Date(tempFilters.endDate * 1000).toISOString().slice(0, 16)
: ""
}
onChange={(e) =>
setTempFilters({
...tempFilters,
endDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
<div className="ml-auto flex gap-2">
<Button size="sm" onClick={handleSearch}>
<Search className="mr-1 h-4 w-4" />
{t("common.search", "查询")}
</Button>
<Button size="sm" variant="outline" onClick={handleReset}>
<X className="mr-1 h-4 w-4" />
{t("common.reset", "重置")}
</Button>
<Button size="sm" variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="default"
onClick={handleSearch}
className="h-8"
>
<Search className="mr-2 h-3.5 w-3.5" />
{t("common.search", "查询")}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleReset}
className="h-8"
>
<X className="mr-2 h-3.5 w-3.5" />
{t("common.reset", "重置")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleRefresh}
className="h-8 px-2"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
</div>
@@ -189,40 +217,34 @@ export function RequestLogTable() {
<div className="h-[400px] animate-pulse rounded bg-gray-100" />
) : (
<>
<div className="rounded-md bg-card/60 shadow-sm overflow-x-auto">
<div className="rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">
{t("usage.time", "时间")}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("usage.provider", "供应商")}
</TableHead>
<TableHead className="min-w-[280px] whitespace-nowrap">
<TableHead>{t("usage.time", "时间")}</TableHead>
<TableHead>{t("usage.provider", "供应商")}</TableHead>
<TableHead className="min-w-[280px]">
{t("usage.billingModel", "计费模型")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
<TableHead className="text-right">
{t("usage.inputTokens", "输入")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
<TableHead className="text-right">
{t("usage.outputTokens", "输出")}
</TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheReadTokens", "缓存读取")}
</TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
<TableHead className="text-right min-w-[90px]">
{t("usage.cacheCreationTokens", "缓存写入")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
<TableHead className="text-right min-w-[90px]">
{t("usage.cacheReadTokens", "缓存读取")}
</TableHead>
<TableHead className="text-right">
{t("usage.totalCost", "成本")}
</TableHead>
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
<TableHead className="text-center min-w-[140px]">
{t("usage.timingInfo", "用时/首字")}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("usage.status", "状态")}
</TableHead>
<TableHead>{t("usage.status", "状态")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -258,10 +280,10 @@ export function RequestLogTable() {
{log.outputTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{log.cacheReadTokens.toLocaleString()}
{log.cacheCreationTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()}
{log.cacheReadTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)}