feat: bump version to 0.14.0
Includes all changes from this release cycle: - Top header navigation (sidebar removed) - Accounts screen decomposition and visual redesign - Dev/prod environment isolation - Usage Analytics with accurate averages, cost reporting, OpenRouter pricing - Monitor moved to Usage with per-request token/cost tracking - Clickable traffic logs with expandable JSON body viewer - OpenCode IDE config in Quick Setup - API Proxy reorganization (flattened tabs) - CI/CD pipeline fixes (release config, Node version, pnpm alignment) - Repository cleanup (~80MB of binaries removed)
This commit is contained in:
1 parent
3752dfda91
commit
a905877c76
67 files changed
+9303
-3862
No files matched your search
@@ -4,17 +4,20 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Current State
|
||||
|
||||
The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basic proxy controls (start/stop, port, protocol, API key, code examples) but leaves significant value on the table. The backend (`ProxyService`, `ProxyModule`) already implements many advanced features with no UI exposure.
|
||||
|
||||
## Proposed New Features
|
||||
|
||||
### 1. Live Traffic Monitor
|
||||
|
||||
- Real-time SSE/WebSocket feed of proxied requests
|
||||
- Show request method, model, account used, latency, token count
|
||||
- Filter by status (success, rate-limited, error)
|
||||
- Inspired by: Request logging already exists in `TrafficLogger`
|
||||
|
||||
### 2. IDE Quick-Connect Panel
|
||||
|
||||
- One-click copy of ready-to-use configurations for:
|
||||
- Cursor (`settings.json` snippet)
|
||||
- OpenCode (`.env` snippet)
|
||||
@@ -24,6 +27,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- QR code for mobile/quick access
|
||||
|
||||
### 3. API Playground
|
||||
|
||||
- Built-in Swagger/OpenAPI UI or custom test panel
|
||||
- Pre-loaded with all supported endpoints:
|
||||
- `/v1/chat/completions` (OpenAI-compatible)
|
||||
@@ -33,6 +37,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Show exact curl equivalent for every request
|
||||
|
||||
### 4. Health Dashboard
|
||||
|
||||
- Real-time metrics cards:
|
||||
- Requests/minute
|
||||
- Average latency (p50, p95, p99)
|
||||
@@ -43,6 +48,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Color-coded status indicators
|
||||
|
||||
### 5. Model Availability Status
|
||||
|
||||
- Live grid showing which models are currently available
|
||||
- Per-model indicators:
|
||||
- Green: available
|
||||
@@ -52,6 +58,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Collapse/expand by provider (Gemini, Anthropic)
|
||||
|
||||
### 6. Error Feed
|
||||
|
||||
- Scrollable log of recent errors
|
||||
- Columns: timestamp, endpoint, model, account, error code, message
|
||||
- Click to expand full request/response
|
||||
@@ -59,6 +66,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Export to JSON/CSV
|
||||
|
||||
### 7. Connection Visualizer
|
||||
|
||||
- Diagram showing: IDE → Proxy → Account Pool → Upstream API
|
||||
- Animated flow dots representing live requests
|
||||
- Highlight currently active account
|
||||
@@ -66,6 +74,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Simple but effective for understanding routing
|
||||
|
||||
### 8. One-Click Diagnostics
|
||||
|
||||
- "Run Diagnostics" button that performs:
|
||||
- Check if proxy port is accessible
|
||||
- Test each configured account
|
||||
@@ -75,6 +84,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Output: green checkmarks or detailed error report with fix suggestions
|
||||
|
||||
### 9. Session Stats
|
||||
|
||||
- Summary of current session since proxy started:
|
||||
- Total requests handled
|
||||
- Total tokens proxied
|
||||
@@ -86,6 +96,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Many of these features require new IPC endpoints in `src/ipc/proxy/router.ts`
|
||||
- Health metrics likely already exist in `ProxyService` but need exposure
|
||||
- Traffic logging exists via `TrafficLogger` — just needs UI
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Current Pain Points
|
||||
|
||||
1. **Visual noise**: Gradient backgrounds (`bg-gradient-to-br`) feel dated and clash with the dark, technical aesthetic
|
||||
2. **Information overload**: All content is visible at once — no progressive disclosure
|
||||
3. **Code examples dominate**: Large, always-visible code blocks push important controls below the fold
|
||||
@@ -14,29 +15,35 @@
|
||||
## Proposed Improvements
|
||||
|
||||
### 1. Remove Gradient Backgrounds
|
||||
|
||||
- Replace `bg-gradient-to-br from-slate-800 to-slate-900` with solid `bg-card` or subtle borders
|
||||
- Use elevation (borders, subtle shadows) instead of gradients to create hierarchy
|
||||
- Align with the rest of the app which uses flat, technical styling
|
||||
|
||||
### 2. Tabs-Based Layout
|
||||
|
||||
Organize the page into tabs:
|
||||
|
||||
- **Status** (default): Service state, quick actions, health metrics
|
||||
- **Routing**: Model mapping, account scheduling, protocol settings
|
||||
- **API Docs**: Code examples, endpoint documentation, IDE configs
|
||||
- **Logs**: Live traffic feed, error history
|
||||
|
||||
### 3. Compact Protocol Selector
|
||||
|
||||
- Current: large buttons with icons
|
||||
- Proposed: `Select` dropdown or compact segmented control
|
||||
- Save horizontal space for more important controls
|
||||
|
||||
### 4. Collapsible Code Examples
|
||||
|
||||
- Wrap each code block in a `Collapsible` or `Accordion`
|
||||
- Show only the title + language icon by default
|
||||
- Expand on click
|
||||
- Add nested tabs for different languages (cURL, Python, JavaScript, TypeScript)
|
||||
|
||||
### 5. Security-Focused API Key Display
|
||||
|
||||
- Mask the API key by default (show `sk-••••••••`)
|
||||
- Reveal on explicit "Show" toggle
|
||||
- Add "Regenerate" button with confirmation dialog
|
||||
@@ -44,6 +51,7 @@ Organize the page into tabs:
|
||||
- Add warning if key is the default/weak key
|
||||
|
||||
### 6. Configurable Model Mapping Table
|
||||
|
||||
- Instead of a static list, show an editable table:
|
||||
- Requested model | Resolved model | Provider | Status
|
||||
- Allow users to add custom mappings
|
||||
@@ -51,21 +59,25 @@ Organize the page into tabs:
|
||||
- Validate that resolved models exist in the account pool
|
||||
|
||||
### 7. Account Quota Context
|
||||
|
||||
- Show the currently selected "primary" account with its quota bar
|
||||
- If auto-switching is enabled, show "Pool Mode: Active (N accounts)"
|
||||
- Link to the account pool page for detailed management
|
||||
|
||||
### 8. Toast Copy Feedback
|
||||
|
||||
- When user clicks "Copy", show a `Toast` or `Sonner` notification
|
||||
- Current: no feedback — user doesn't know if copy succeeded
|
||||
- Message: "Copied to clipboard" or "Failed to copy"
|
||||
|
||||
### 9. Health Metrics Stub
|
||||
|
||||
- Even if backend metrics aren't wired yet, create the UI skeleton
|
||||
- Show placeholder charts with "Coming soon" badge
|
||||
- This sets expectations and makes the page feel more complete
|
||||
|
||||
### 10. Empty States
|
||||
|
||||
- When proxy is stopped, show a clear empty state:
|
||||
- Icon: `ServerOff` from lucide
|
||||
- Message: "Proxy is not running"
|
||||
@@ -75,6 +87,7 @@ Organize the page into tabs:
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Density over whitespace**: This is a developer tool — show more information per pixel
|
||||
- **Progressive disclosure**: Hide advanced options behind tabs/accordions
|
||||
- **Immediate feedback**: Every action should have a visible result (toast, spinner, state change)
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Discovery
|
||||
|
||||
The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many sophisticated features that are **completely invisible** in the current UI. The proxy page only shows: port, protocol, API key, and basic start/stop.
|
||||
|
||||
## Hidden Backend Features
|
||||
|
||||
### 1. Advanced Routing & Scheduling Controls
|
||||
|
||||
**Backend location**: `proxy.service.ts`, `proxy-scheduler.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- `scheduler.maxRequestsPerAccount`: Limit requests per account before switching
|
||||
- `scheduler.maxWaitTime`: Max time to wait for an available account
|
||||
- `scheduler.retryAttempts`: Number of retries per request
|
||||
@@ -20,8 +23,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. These are hardcoded or set via config files only.
|
||||
|
||||
### 2. Live Traffic Inspector
|
||||
|
||||
**Backend location**: `traffic-logger.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- `TrafficLogger` captures every request/response with metadata
|
||||
- Fields: timestamp, requestId, accountId, model, promptTokens, completionTokens, latency, status, error
|
||||
- Log rotation and cleanup policies
|
||||
@@ -30,8 +35,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Logs are only written to files.
|
||||
|
||||
### 3. Circuit Breaker Dashboard
|
||||
|
||||
**Backend location**: `proxy-circuit-breaker.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Per-account circuit breaker state: `CLOSED`, `OPEN`, `HALF_OPEN`
|
||||
- Failure threshold and recovery timeout
|
||||
- When open: requests fail fast without hitting the API
|
||||
@@ -40,8 +47,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Circuit breaker state is internal only.
|
||||
|
||||
### 4. Parity Testing Sandbox
|
||||
|
||||
**Backend location**: `proxy-parity.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Send identical requests to multiple models simultaneously
|
||||
- Compare responses for consistency
|
||||
- Detect model drift or API changes
|
||||
@@ -50,8 +59,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Only accessible via manual API calls or scripts.
|
||||
|
||||
### 5. Custom Model Mapping Table
|
||||
|
||||
**Backend location**: `proxy-model-mapper.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Dynamic model resolution: `claude-opus-4` → `gemini-3-flash`
|
||||
- User-defined mappings stored in database
|
||||
- Regex-based model matching
|
||||
@@ -60,8 +71,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: A static, read-only list is shown. No editing capability.
|
||||
|
||||
### 6. Upstream Proxy Configuration
|
||||
|
||||
**Backend location**: `proxy-upstream.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- `upstreamProxy.url`: Route all traffic through another proxy
|
||||
- `upstreamProxy.auth`: Basic auth or bearer token for upstream
|
||||
- `upstreamProxy.bypassList`: Domains to bypass
|
||||
@@ -69,8 +82,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Set via environment variables only.
|
||||
|
||||
### 7. IDE Integration Quick-Setup
|
||||
|
||||
**Backend location**: `proxy-config.service.ts` — `generateIdeConfig()`
|
||||
**What's implemented**:
|
||||
|
||||
- Generates ready-to-paste configs for:
|
||||
- Cursor (`settings.json`)
|
||||
- VS Code + Cline/Roo Code
|
||||
@@ -82,8 +97,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: Code examples are generic curl snippets. No IDE-specific configs.
|
||||
|
||||
### 8. Health & Performance Metrics
|
||||
|
||||
**Backend location**: `proxy-metrics.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Request latency histogram (p50, p95, p99)
|
||||
- Requests per minute counter
|
||||
- Error rate by status code
|
||||
@@ -94,8 +111,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Metrics are only exposed via a `/metrics` endpoint for Prometheus.
|
||||
|
||||
### 9. Request Replay & Debug Tool
|
||||
|
||||
**Backend location**: `proxy-replay.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Store recent requests (last 100 by default)
|
||||
- Replay any request with one click
|
||||
- Modify headers/body before replay
|
||||
@@ -105,8 +124,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None.
|
||||
|
||||
### 10. Global Timeout & Max Wait Controls
|
||||
|
||||
**Backend location**: `proxy-config.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- `requestTimeout`: Global timeout for all upstream requests
|
||||
- `connectTimeout`: TCP connection timeout
|
||||
- `maxWaitTime`: Max time to wait for an available account
|
||||
@@ -117,7 +138,9 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
Create a new "Advanced" tab on the proxy page that exposes these features progressively:
|
||||
|
||||
1. **Phase 1**: Read-only views (circuit breaker status, health metrics, traffic logs)
|
||||
2. **Phase 2**: Interactive controls (model mapping editor, scheduler settings)
|
||||
3. **Phase 3**: Power-user tools (replay, parity testing, upstream proxy config)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Current State
|
||||
|
||||
`src/routes/proxy.tsx` is a **724-line monolith** containing:
|
||||
|
||||
- Route definition
|
||||
- State management (useState, useEffect, useMemo)
|
||||
- Data fetching (useQuery equivalents for proxy config, local IPs)
|
||||
@@ -14,6 +16,7 @@
|
||||
- Constants and helper functions
|
||||
|
||||
## Target Architecture
|
||||
|
||||
Split into **7+ files** following the project's conventions:
|
||||
|
||||
```
|
||||
@@ -39,7 +42,9 @@ src/
|
||||
## File Breakdown
|
||||
|
||||
### 1. `src/hooks/useProxyConfig.ts`
|
||||
|
||||
**Responsibility**: Fetch and mutate proxy configuration
|
||||
|
||||
```typescript
|
||||
export function useProxyConfig() {
|
||||
// Fetches from ipc.client.proxy.getConfig()
|
||||
@@ -49,7 +54,9 @@ export function useProxyConfig() {
|
||||
```
|
||||
|
||||
### 2. `src/hooks/useLocalIps.ts`
|
||||
|
||||
**Responsibility**: Get local IP addresses for the access banner
|
||||
|
||||
```typescript
|
||||
export function useLocalIps() {
|
||||
// Fetches from ipc.client.system.getLocalIps()
|
||||
@@ -58,7 +65,9 @@ export function useLocalIps() {
|
||||
```
|
||||
|
||||
### 3. `src/hooks/useProxyExamples.ts`
|
||||
|
||||
**Responsibility**: Generate code examples based on current config
|
||||
|
||||
```typescript
|
||||
export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
||||
// Returns memoized examples for curl, Python, Node.js
|
||||
@@ -67,7 +76,9 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
||||
```
|
||||
|
||||
### 4. `src/components/proxy/ProxyServiceCard.tsx`
|
||||
|
||||
**Responsibility**: The main service control card
|
||||
|
||||
- Start/stop toggle
|
||||
- Port input
|
||||
- Protocol selector
|
||||
@@ -75,26 +86,34 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
||||
- Status indicator
|
||||
|
||||
### 5. `src/components/proxy/ProxyModelMappingCard.tsx`
|
||||
|
||||
**Responsibility**: Model mapping display/editor
|
||||
|
||||
- Read-only list (current state)
|
||||
- Future: editable table with add/remove
|
||||
- Show provider badges
|
||||
|
||||
### 6. `src/components/proxy/ProxyUsageExamplesCard.tsx`
|
||||
|
||||
**Responsibility**: Code examples section
|
||||
|
||||
- Collapsible/accordion layout
|
||||
- Tabs for different languages
|
||||
- Copy-to-clipboard buttons
|
||||
- Syntax highlighting (if available)
|
||||
|
||||
### 7. `src/components/proxy/ProxyLocalAccessBanner.tsx`
|
||||
|
||||
**Responsibility**: "Access your proxy locally" banner
|
||||
|
||||
- Show local IPs
|
||||
- QR code (future)
|
||||
- Copy URL buttons
|
||||
|
||||
### 8. `src/constants/proxy.ts`
|
||||
|
||||
**Responsibility**: Constants
|
||||
|
||||
```typescript
|
||||
export const DEFAULT_PROXY_PORT = 3000;
|
||||
export const PROTOCOLS = ['http', 'https'] as const;
|
||||
@@ -102,7 +121,9 @@ export const EXAMPLE_MODELS = ['gpt-4o', 'claude-3-opus', 'gemini-pro'];
|
||||
```
|
||||
|
||||
### 9. `src/utils/proxy.ts`
|
||||
|
||||
**Responsibility**: Pure helper functions
|
||||
|
||||
```typescript
|
||||
export function buildProxyUrl(protocol: string, ip: string, port: number): string;
|
||||
export function maskApiKey(key: string): string;
|
||||
@@ -141,6 +162,7 @@ export const Route = createFileRoute('/proxy')({ component: ProxyPage });
|
||||
```
|
||||
|
||||
## Migration Steps
|
||||
|
||||
1. Create new files (hooks, components, constants, utils)
|
||||
2. Move code from `proxy.tsx` to appropriate files
|
||||
3. Update imports in `proxy.tsx`
|
||||
@@ -149,6 +171,7 @@ export const Route = createFileRoute('/proxy')({ component: ProxyPage });
|
||||
6. Verify no regression in functionality
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Testability**: Each hook and component can be unit tested in isolation
|
||||
- **Reusability**: `useLocalIps` can be used in other pages (e.g., settings)
|
||||
- **Maintainability**: 50-line route file vs. 724-line monolith
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Severity Legend
|
||||
|
||||
- 🔴 **Critical**: Bugs or crashes
|
||||
- 🟡 **High**: Performance or reliability issues
|
||||
- 🟢 **Medium**: Maintainability or best practice violations
|
||||
@@ -11,21 +12,26 @@
|
||||
## Issues Found
|
||||
|
||||
### 1. 🔴 State Duplication Between Proxy Config and Local State
|
||||
|
||||
**Location**: Lines ~120-180
|
||||
**Issue**: The component maintains both `proxyConfig` (from IPC) and local `useState` copies for `port`, `protocol`, `apiKey`. These can drift out of sync if the backend config changes externally.
|
||||
**Fix**: Use a single source of truth. Either:
|
||||
|
||||
- Derive local state from `proxyConfig` with `useEffect` sync
|
||||
- Or use `useQuery` with `staleTime: 0` and no local cache
|
||||
|
||||
### 2. 🟡 Race Condition in Start/Stop Toggle
|
||||
|
||||
**Location**: Lines ~200-250
|
||||
**Issue**: If user clicks start/stop rapidly, multiple IPC calls fire in parallel. The UI shows intermediate states that may not reflect the actual backend state.
|
||||
**Fix**: Disable the toggle button while a transition is in progress. Add an `isTransitioning` flag.
|
||||
|
||||
### 3. 🟢 Clipboard Error Handling
|
||||
|
||||
**Location**: Lines ~300-320
|
||||
**Issue**: `navigator.clipboard.writeText()` can fail (permission denied, not in secure context). The code has no try/catch.
|
||||
**Fix**:
|
||||
|
||||
```typescript
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
@@ -38,18 +44,22 @@ async function copyToClipboard(text: string) {
|
||||
```
|
||||
|
||||
### 4. 🟢 Unstable Callbacks Causing Re-renders
|
||||
|
||||
**Location**: Throughout
|
||||
**Issue**: Event handlers are defined inline in JSX, causing child components to re-render unnecessarily.
|
||||
**Fix**: Wrap handlers in `useCallback`:
|
||||
|
||||
```typescript
|
||||
const handleStart = useCallback(() => startProxy(), [startProxy]);
|
||||
const handleStop = useCallback(() => stopProxy(), [stopProxy]);
|
||||
```
|
||||
|
||||
### 5. 🟡 Memory Leak in setTimeout
|
||||
|
||||
**Location**: Lines ~400-420
|
||||
**Issue**: A `setTimeout` is used for a "copy feedback" animation but is never cleared on unmount.
|
||||
**Fix**: Use `useEffect` cleanup:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setCopied(false), 2000);
|
||||
@@ -58,24 +68,29 @@ useEffect(() => {
|
||||
```
|
||||
|
||||
### 6. 🟢 Missing Loading States for Mutations
|
||||
|
||||
**Location**: Lines ~200-250
|
||||
**Issue**: When starting/stopping the proxy, there's no loading spinner or disabled state on the button.
|
||||
**Fix**: Add `isPending` state to the mutation hook and disable the button + show spinner while pending.
|
||||
|
||||
### 7. 🟢 Hardcoded i18n Strings
|
||||
|
||||
**Location**: Throughout
|
||||
**Issue**: Many UI strings are hardcoded in English instead of using `useTranslation()`:
|
||||
|
||||
- "Proxy Service"
|
||||
- "Start Proxy"
|
||||
- "Stop Proxy"
|
||||
- "Copy"
|
||||
- "Copied!"
|
||||
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
|
||||
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
|
||||
|
||||
### 8. 🟢 Magic Numbers
|
||||
|
||||
**Location**: Lines ~450-500
|
||||
**Issue**: Port validation uses hardcoded `3000`, `65535` without constants.
|
||||
**Fix**:
|
||||
|
||||
```typescript
|
||||
const MIN_PORT = 1024;
|
||||
const MAX_PORT = 65535;
|
||||
@@ -83,9 +98,11 @@ const DEFAULT_PORT = 3000;
|
||||
```
|
||||
|
||||
### 9. 🟢 Unsafe Type Cast
|
||||
|
||||
**Location**: Lines ~550-600
|
||||
**Issue**: `event.target.value` is cast to `Protocol` without validation.
|
||||
**Fix**:
|
||||
|
||||
```typescript
|
||||
const protocol = event.target.value;
|
||||
if (!PROTOCOLS.includes(protocol)) return;
|
||||
@@ -93,22 +110,27 @@ setProtocol(protocol as Protocol);
|
||||
```
|
||||
|
||||
### 10. 🟡 Dangerous Parsing of Local IPs
|
||||
|
||||
**Location**: Lines ~650-700
|
||||
**Issue**: `localIps` is parsed from a shell command output using string splitting that could break on unexpected formats.
|
||||
**Fix**: Use a robust parser or, better, expose `os.networkInterfaces()` via a safe IPC endpoint.
|
||||
|
||||
### 11. 🟢 Unnecessary Re-renders from Inline Objects
|
||||
|
||||
**Location**: Lines ~350-380
|
||||
**Issue**: Inline style objects and arrays are recreated on every render, breaking `React.memo` optimizations in child components.
|
||||
**Fix**: Extract static styles outside the component or memoize:
|
||||
|
||||
```typescript
|
||||
const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
|
||||
```
|
||||
|
||||
### 12. 🟢 No Validation on Port Input
|
||||
|
||||
**Location**: Lines ~150-170
|
||||
**Issue**: User can type any value into the port field, including non-numbers, negatives, or out-of-range values.
|
||||
**Fix**: Add input validation:
|
||||
|
||||
```typescript
|
||||
<input
|
||||
type="number"
|
||||
@@ -125,6 +147,7 @@ const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
- Add unit tests for `useProxyConfig` hook (mock IPC client)
|
||||
- Test race conditions: rapid start/stop clicks
|
||||
- Test clipboard failure path
|
||||
|
||||
@@ -6,41 +6,45 @@
|
||||
## File Map
|
||||
|
||||
### Frontend
|
||||
| File | Lines | Role |
|
||||
|------|-------|------|
|
||||
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
|
||||
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
|
||||
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
|
||||
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
|
||||
|
||||
| File | Lines | Role |
|
||||
| -------------------------------------------- | ----- | ---------------------------------------- |
|
||||
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
|
||||
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
|
||||
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
|
||||
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
|
||||
|
||||
### Backend (NestJS)
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
|
||||
| `src/server/modules/proxy/proxy.service.ts` | 1883 lines — core proxy logic, all handlers |
|
||||
| `src/server/modules/proxy/proxy.controller.ts` | HTTP endpoints for proxy control |
|
||||
| `src/server/modules/proxy/proxy-scheduler.service.ts` | Account rotation & scheduling |
|
||||
| `src/server/modules/proxy/proxy-circuit-breaker.service.ts` | Per-account circuit breaker |
|
||||
| `src/server/modules/proxy/traffic-logger.service.ts` | Request/response logging |
|
||||
| `src/server/modules/proxy/proxy-metrics.service.ts` | Prometheus metrics |
|
||||
| `src/server/modules/proxy/proxy-model-mapper.service.ts` | Model name resolution |
|
||||
| `src/server/modules/proxy/proxy-parity.service.ts` | Parity testing |
|
||||
| `src/server/modules/proxy/proxy-replay.service.ts` | Request replay |
|
||||
| `src/server/modules/proxy/proxy-config.service.ts` | Config management |
|
||||
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
|
||||
|
||||
| File | Role |
|
||||
| ----------------------------------------------------------- | ------------------------------------------- |
|
||||
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
|
||||
| `src/server/modules/proxy/proxy.service.ts` | 1883 lines — core proxy logic, all handlers |
|
||||
| `src/server/modules/proxy/proxy.controller.ts` | HTTP endpoints for proxy control |
|
||||
| `src/server/modules/proxy/proxy-scheduler.service.ts` | Account rotation & scheduling |
|
||||
| `src/server/modules/proxy/proxy-circuit-breaker.service.ts` | Per-account circuit breaker |
|
||||
| `src/server/modules/proxy/traffic-logger.service.ts` | Request/response logging |
|
||||
| `src/server/modules/proxy/proxy-metrics.service.ts` | Prometheus metrics |
|
||||
| `src/server/modules/proxy/proxy-model-mapper.service.ts` | Model name resolution |
|
||||
| `src/server/modules/proxy/proxy-parity.service.ts` | Parity testing |
|
||||
| `src/server/modules/proxy/proxy-replay.service.ts` | Request replay |
|
||||
| `src/server/modules/proxy/proxy-config.service.ts` | Config management |
|
||||
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
|
||||
|
||||
### IPC Layer
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/ipc/router.ts` | Main IPC router composition |
|
||||
|
||||
| File | Role |
|
||||
| ------------------------- | ---------------------------- |
|
||||
| `src/ipc/router.ts` | Main IPC router composition |
|
||||
| `src/ipc/proxy/router.ts` | Proxy-specific IPC endpoints |
|
||||
| `src/ipc/manager.ts` | IPC client initialization |
|
||||
| `src/ipc/manager.ts` | IPC client initialization |
|
||||
|
||||
### Types & Schema
|
||||
| File | Role |
|
||||
|------|------|
|
||||
|
||||
| File | Role |
|
||||
| --------------------- | ------------------------------------------------ |
|
||||
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
|
||||
| `src/types/proxy.ts` | Request/response types |
|
||||
| `src/types/proxy.ts` | Request/response types |
|
||||
|
||||
## Hidden ProxyConfig Fields
|
||||
|
||||
@@ -124,6 +128,7 @@ interface ProxyConfig {
|
||||
```
|
||||
|
||||
## Key Observations
|
||||
|
||||
1. **Backend is significantly more capable than the UI suggests** — 12 services vs. 1 page
|
||||
2. **IPC layer is thin** — most backend services are not exposed via IPC, only HTTP
|
||||
3. **ProxyConfig schema is the source of truth** — it documents everything the backend can do
|
||||
@@ -131,6 +136,7 @@ interface ProxyConfig {
|
||||
5. **Separation of concerns is good on backend**, terrible on frontend (724-line monolith)
|
||||
|
||||
## Recommended Architecture Changes
|
||||
|
||||
1. Extend `src/ipc/proxy/router.ts` to expose all `ProxyConfig` fields
|
||||
2. Add IPC endpoints for:
|
||||
- `proxy.getTrafficLogs()`
|
||||
|
||||
+22
-22
@@ -63,17 +63,17 @@ npm start
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
|
||||
| `npm run lint` | Run ESLint |
|
||||
| `npm run format` | Check formatting with Prettier |
|
||||
| `npm run format:write` | Auto-format code with Prettier |
|
||||
| `npm run type-check` | Run TypeScript type check |
|
||||
| `npm test` | Run unit tests with Vitest |
|
||||
| `npm run test:e2e` | Run E2E tests with Playwright |
|
||||
| `npm run test:all` | Run all tests (unit + E2E) |
|
||||
| `npm run make` | Build production installers |
|
||||
| Command | Description |
|
||||
| ---------------------- | ------------------------------------------------------- |
|
||||
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
|
||||
| `npm run lint` | Run ESLint |
|
||||
| `npm run format` | Check formatting with Prettier |
|
||||
| `npm run format:write` | Auto-format code with Prettier |
|
||||
| `npm run type-check` | Run TypeScript type check |
|
||||
| `npm test` | Run unit tests with Vitest |
|
||||
| `npm run test:e2e` | Run E2E tests with Playwright |
|
||||
| `npm run test:all` | Run all tests (unit + E2E) |
|
||||
| `npm run make` | Build production installers |
|
||||
|
||||
### Project Structure
|
||||
|
||||
@@ -135,17 +135,17 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Commits
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Description | Release |
|
||||
|------|-------------|---------|
|
||||
| `feat` | A new feature | **minor** |
|
||||
| `fix` | A bug fix | **patch** |
|
||||
| `perf` | Performance improvement | **patch** |
|
||||
| `refactor` | Code change (no bug fix or feature) | **patch** |
|
||||
| `docs` | Documentation only | no release |
|
||||
| `style` | Formatting, whitespace | no release |
|
||||
| `test` | Adding or fixing tests | no release |
|
||||
| `chore` | Build process, tooling | no release |
|
||||
| `ci` | CI/CD changes | no release |
|
||||
| Type | Description | Release |
|
||||
| ---------- | ----------------------------------- | ---------- |
|
||||
| `feat` | A new feature | **minor** |
|
||||
| `fix` | A bug fix | **patch** |
|
||||
| `perf` | Performance improvement | **patch** |
|
||||
| `refactor` | Code change (no bug fix or feature) | **patch** |
|
||||
| `docs` | Documentation only | no release |
|
||||
| `style` | Formatting, whitespace | no release |
|
||||
| `test` | Adding or fixing tests | no release |
|
||||
| `chore` | Build process, tooling | no release |
|
||||
| `ci` | CI/CD changes | no release |
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
+10
-10
@@ -196,16 +196,16 @@ Model: gemini-3-flash
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
| Categoría | Tecnologías |
|
||||
|-----------|------------|
|
||||
| **Core** | Electron, React 19, TypeScript |
|
||||
| **Build** | Vite, Electron Forge |
|
||||
| **Estilos** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||
| **Estado** | TanStack Query, TanStack Router |
|
||||
| **Backend** | NestJS (gateway interno), ORPC (RPC type-safe) |
|
||||
| **Base de Datos** | Better-SQLite3, Drizzle ORM |
|
||||
| **Gráficos** | Nivo (Line, Bar, Pie) |
|
||||
| **Testing** | Vitest, Playwright |
|
||||
| Categoría | Tecnologías |
|
||||
| ----------------- | ---------------------------------------------- |
|
||||
| **Core** | Electron, React 19, TypeScript |
|
||||
| **Build** | Vite, Electron Forge |
|
||||
| **Estilos** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||
| **Estado** | TanStack Query, TanStack Router |
|
||||
| **Backend** | NestJS (gateway interno), ORPC (RPC type-safe) |
|
||||
| **Base de Datos** | Better-SQLite3, Drizzle ORM |
|
||||
| **Gráficos** | Nivo (Line, Bar, Pie) |
|
||||
| **Testing** | Vitest, Playwright |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+11
-12
@@ -159,7 +159,6 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/main.png" alt="Main" width="80%" />
|
||||
</p>
|
||||
@@ -223,17 +222,17 @@ Model: gemini-3-flash (ou qualquer modelo mapeado)
|
||||
|
||||
## 🛠️ Stack Tecnológica
|
||||
|
||||
| Categoria | Tecnologias |
|
||||
|-----------|------------|
|
||||
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
||||
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) |
|
||||
| **Estilização** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
|
||||
| **Estado** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **Backend** | [NestJS](https://nestjs.com/) (gateway interno), [ORPC](https://orpc.unnoq.com/) (RPC type-safe) |
|
||||
| **Banco de Dados** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3), [Drizzle ORM](https://orm.drizzle.team/) |
|
||||
| **Gráficos** | [Nivo](https://nivo.rocks/) (Line, Bar, Pie) |
|
||||
| **i18n** | [react-i18next](https://react.i18next.com/) |
|
||||
| **Testes** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
|
||||
| Categoria | Tecnologias |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
||||
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) |
|
||||
| **Estilização** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
|
||||
| **Estado** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **Backend** | [NestJS](https://nestjs.com/) (gateway interno), [ORPC](https://orpc.unnoq.com/) (RPC type-safe) |
|
||||
| **Banco de Dados** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3), [Drizzle ORM](https://orm.drizzle.team/) |
|
||||
| **Gráficos** | [Nivo](https://nivo.rocks/) (Line, Bar, Pie) |
|
||||
| **i18n** | [react-i18next](https://react.i18next.com/) |
|
||||
| **Testes** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-10
@@ -194,16 +194,16 @@ Model: gemini-3-flash
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 分类 | 技术 |
|
||||
|------|------|
|
||||
| **核心** | Electron, React 19, TypeScript |
|
||||
| **构建** | Vite, Electron Forge |
|
||||
| **样式** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||
| **状态** | TanStack Query, TanStack Router |
|
||||
| **后端** | NestJS (内部网关), ORPC (类型安全 RPC) |
|
||||
| **数据库** | Better-SQLite3, Drizzle ORM |
|
||||
| **图表** | Nivo (Line, Bar, Pie) |
|
||||
| **测试** | Vitest, Playwright |
|
||||
| 分类 | 技术 |
|
||||
| ---------- | --------------------------------------- |
|
||||
| **核心** | Electron, React 19, TypeScript |
|
||||
| **构建** | Vite, Electron Forge |
|
||||
| **样式** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||
| **状态** | TanStack Query, TanStack Router |
|
||||
| **后端** | NestJS (内部网关), ORPC (类型安全 RPC) |
|
||||
| **数据库** | Better-SQLite3, Drizzle ORM |
|
||||
| **图表** | Nivo (Line, Bar, Pie) |
|
||||
| **测试** | Vitest, Playwright |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
+48
-36
@@ -2,16 +2,16 @@
|
||||
|
||||
## 1. Nome e Descrição
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **Nome** | Gemini Nexus |
|
||||
| **Product Name** | Gemini Nexus |
|
||||
| **Package Name** | gemini-nexus |
|
||||
| **Versão** | 0.11.1 |
|
||||
| **Descrição EN** | Professional multi-account manager for Google Gemini & Claude AI |
|
||||
| Campo | Valor |
|
||||
| ------------------- | -------------------------------------------------------------------- |
|
||||
| **Nome** | Gemini Nexus |
|
||||
| **Product Name** | Gemini Nexus |
|
||||
| **Package Name** | gemini-nexus |
|
||||
| **Versão** | 0.11.1 |
|
||||
| **Descrição EN** | Professional multi-account manager for Google Gemini & Claude AI |
|
||||
| **Descrição PT-BR** | Gerenciador multi-contas profissional para Google Gemini & Claude AI |
|
||||
| **Autor** | evandrodevbr / Draculabo |
|
||||
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
|
||||
| **Autor** | evandrodevbr / Draculabo |
|
||||
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona como um **hub central (nexus)** para desenvolvedores que usam IDEs com IA.
|
||||
|
||||
### Funcionalidades Principais
|
||||
|
||||
- **Pool ilimitado de contas**: Adicionar múltiplas contas Google Gemini / Claude via OAuth
|
||||
- **Auto-switching inteligente**: Troca automática de conta quando a cota acaba ou há rate limit
|
||||
- **Monitoramento em tempo real**: Barras de progresso coloridas para visualizar uso de quota
|
||||
@@ -27,6 +28,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
- **Segurança**: Criptografia AES-256-GCM para dados sensíveis
|
||||
|
||||
### Público-Alvo
|
||||
|
||||
- Desenvolvedores que usam IDEs com integração AI (Cursor, Windsurf, VS Code, Claude Code, etc.)
|
||||
- Power users que precisam alternar entre múltiplas contas de IA
|
||||
- Usuários que precisam de um proxy API local confiável
|
||||
@@ -46,28 +48,32 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
## 4. Estilo Visual Atual
|
||||
|
||||
### Tema
|
||||
|
||||
- Dual theme (claro/escuro) com detecção automática do sistema
|
||||
- Extremamente minimalista e quase monocromático
|
||||
|
||||
### Paleta de Cores (Tailwind OKLCH)
|
||||
|
||||
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|
||||
|------|-----------|------------|---------|-------------|----------|
|
||||
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
|
||||
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
|
||||
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|
||||
| ---------- | ------------------------------ | --------------------- | ------- | --------------- | -------- |
|
||||
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
|
||||
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
|
||||
|
||||
### Cores Funcionais
|
||||
|
||||
- **Emerald/Green (`#10b981`)** — status ativo, quota alta, running
|
||||
- **Amber/Yellow** — quota média, alertas
|
||||
- **Rose/Red** — quota baixa, erro, stopped
|
||||
- **Blue** — AI credits, informações premium
|
||||
|
||||
### Tipografia
|
||||
|
||||
- **Geist** (sans-serif) — fonte principal moderna e geométrica (da Vercel)
|
||||
- **Geist Mono** (monospace) — dados técnicos, números de quota
|
||||
- **Tomorrow** (display/futurista) — possíveis títulos estilizados
|
||||
|
||||
### Características de UI
|
||||
|
||||
- Bordas arredondadas (`0.5rem` / 8px)
|
||||
- Sombras sutis
|
||||
- Cards com hover states suaves
|
||||
@@ -77,45 +83,50 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
|
||||
## 5. Assets Visuais Existentes
|
||||
|
||||
| Arquivo | Descrição |
|
||||
|---------|-----------|
|
||||
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
|
||||
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
|
||||
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
|
||||
| `docs/assets/screenshot-setting.png` | Screenshot de settings |
|
||||
| `src/assets/icon.png` | Ícone do app (também é o logo antigo) |
|
||||
| `src/assets/tray.png` | Ícone da system tray |
|
||||
| `images/icon.ico` / `images/icon.icns` | Ícones de build para Windows/Mac |
|
||||
| `images/favicon-*.png` | Favicons em múltiplos tamanhos |
|
||||
| `images/32x32.png`, `64x64.png`, `128x128.png`, `[email protected]` | Ícones para AppImage Linux |
|
||||
| `src/assets/fonts/geist/` | Fonte Geist (principal) |
|
||||
| `src/assets/fonts/geist-mono/` | Fonte Geist Mono (código/dados) |
|
||||
| `src/assets/fonts/tomorrow/` | Fonte Tomorrow (display, 4 pesos) |
|
||||
| Arquivo | Descrição |
|
||||
| ---------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
|
||||
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
|
||||
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
|
||||
| `docs/assets/screenshot-setting.png` | Screenshot de settings |
|
||||
| `src/assets/icon.png` | Ícone do app (também é o logo antigo) |
|
||||
| `src/assets/tray.png` | Ícone da system tray |
|
||||
| `images/icon.ico` / `images/icon.icns` | Ícones de build para Windows/Mac |
|
||||
| `images/favicon-*.png` | Favicons em múltiplos tamanhos |
|
||||
| `images/32x32.png`, `64x64.png`, `128x128.png`, `[email protected]` | Ícones para AppImage Linux |
|
||||
| `src/assets/fonts/geist/` | Fonte Geist (principal) |
|
||||
| `src/assets/fonts/geist-mono/` | Fonte Geist Mono (código/dados) |
|
||||
| `src/assets/fonts/tomorrow/` | Fonte Tomorrow (display, 4 pesos) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Conceitos Visuais Sugeridos para a Logo
|
||||
|
||||
### A. Nodo / Conexão (Nexus)
|
||||
|
||||
- Ponto central conectado a múltiplos pontos (contas gerenciadas)
|
||||
- Círculo central com 2-3 linhas finas irradiando
|
||||
- Dois círculos interligados (Gemini + Claude)
|
||||
|
||||
### B. Infinito / Loop (Gemini + Proxy)
|
||||
|
||||
- Símbolo de infinito (∞) modificado como circuito/loop de dados
|
||||
- Referência à dualidade "Gemini" (gêmeos)
|
||||
- Curva contínua elegante com seta indicando fluxo
|
||||
|
||||
### C. Cubo / Hexágono (Nexus / Hub)
|
||||
|
||||
- Hexágono ou cubo isométrico minimalista
|
||||
- Linhas finas, perspectiva suave
|
||||
- Face "aberta" ou conectada
|
||||
|
||||
### D. Monograma "GN"
|
||||
|
||||
- Letras "G" e "N" abstraídas como nodo de rede
|
||||
- Estilo geométrico, similar a Tomorrow ou Geist
|
||||
|
||||
### E. Proxy / Pipe (Fluxo de Dados)
|
||||
|
||||
- Duas barras paralelas com seta passando entre elas
|
||||
- "Pipe" minimalista — conduz tráfego entre APIs
|
||||
|
||||
@@ -123,15 +134,15 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
|
||||
## 7. Diretrizes para a Nova Logo
|
||||
|
||||
| Elemento | Especificação |
|
||||
|----------|---------------|
|
||||
| **Estilo** | Minimalista, geométrica, precisa, técnica |
|
||||
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
|
||||
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
|
||||
| **Versões necessárias** | Dark mode e light mode |
|
||||
| **Tamanhos** | De 16x16 (tray) até 512x512 (macOS) |
|
||||
| **Monocromática** | Deve funcionar em preto e branco puro |
|
||||
| **Tipografia do wordmark** | Geométrica sans-serif, peso medium/semibold, tracking levemente expandido |
|
||||
| Elemento | Especificação |
|
||||
| ---------------------------- | ------------------------------------------------------------------------- |
|
||||
| **Estilo** | Minimalista, geométrica, precisa, técnica |
|
||||
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
|
||||
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
|
||||
| **Versões necessárias** | Dark mode e light mode |
|
||||
| **Tamanhos** | De 16x16 (tray) até 512x512 (macOS) |
|
||||
| **Monocromática** | Deve funcionar em preto e branco puro |
|
||||
| **Tipografia do wordmark** | Geométrica sans-serif, peso medium/semibold, tracking levemente expandido |
|
||||
|
||||
---
|
||||
|
||||
@@ -140,6 +151,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
O app pede uma logo que evoque **conexão/nexo** (Nexus) com **múltiplos pontos** (contas) de forma geométrica e limpa, usando primariamente preto/branco com um toque sutil de verde (emerald) para representar o estado "ativo/online" central ao propósito do app.
|
||||
|
||||
A logo deve transmitir:
|
||||
|
||||
- **Tecnologia** e **precisão**
|
||||
- **Conectividade** e **hub central**
|
||||
- **Profissionalismo** e **confiança**
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gemini-nexus",
|
||||
"productName": "Gemini Nexus",
|
||||
"version": "0.13.1",
|
||||
"version": "0.14.0",
|
||||
"description": "Gemini Nexus - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
|
||||
Generated
+8546
-2987
File diff suppressed because it is too large.
Load diff
+75
-24
@@ -41,7 +41,9 @@ function color(code, text) {
|
||||
}
|
||||
|
||||
function header(text) {
|
||||
console.log(`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`);
|
||||
console.log(
|
||||
`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`,
|
||||
);
|
||||
}
|
||||
|
||||
function success(text) {
|
||||
@@ -145,10 +147,13 @@ function checkWindows() {
|
||||
|
||||
for (const vswhere of vswherePaths) {
|
||||
try {
|
||||
const output = execSync(`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`, {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
const output = execSync(
|
||||
`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`,
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
},
|
||||
).trim();
|
||||
|
||||
if (output) {
|
||||
vsFound = true;
|
||||
@@ -163,10 +168,9 @@ function checkWindows() {
|
||||
// Check registry fallback
|
||||
if (!vsFound) {
|
||||
try {
|
||||
execSync(
|
||||
'reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0',
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
execSync('reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0', {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
vsFound = true;
|
||||
success('Visual Studio 2022 found (registry)');
|
||||
} catch {
|
||||
@@ -198,7 +202,10 @@ function checkMacOS() {
|
||||
const results = { ok: true, missing: [] };
|
||||
|
||||
try {
|
||||
const xcodePath = execSync('xcode-select -p', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
|
||||
const xcodePath = execSync('xcode-select -p', {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (xcodePath) {
|
||||
success(`Xcode CLT found at: ${xcodePath}`);
|
||||
}
|
||||
@@ -323,14 +330,26 @@ async function installWindows() {
|
||||
// Check if winget is available
|
||||
if (!commandExists('winget')) {
|
||||
console.log('');
|
||||
console.log(color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'));
|
||||
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
|
||||
console.log(
|
||||
color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'),
|
||||
);
|
||||
console.log(
|
||||
color(
|
||||
CYAN,
|
||||
' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022',
|
||||
),
|
||||
);
|
||||
console.log('');
|
||||
console.log(' During installation, select:');
|
||||
console.log(color(BOLD, ' "Desktop development with C++" workload'));
|
||||
console.log('');
|
||||
console.log(' Or install via PowerShell (as Administrator):');
|
||||
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
|
||||
console.log(
|
||||
color(
|
||||
DIM,
|
||||
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -398,7 +417,9 @@ async function installLinux() {
|
||||
}
|
||||
|
||||
const status = await runCommand('sudo', [
|
||||
'apt-get', 'install', '-y',
|
||||
'apt-get',
|
||||
'install',
|
||||
'-y',
|
||||
'build-essential',
|
||||
'python3',
|
||||
'libsecret-1-dev',
|
||||
@@ -421,7 +442,10 @@ async function installLinux() {
|
||||
}
|
||||
|
||||
const status = await runCommand('sudo', [
|
||||
'pacman', '-S', '--needed', '--noconfirm',
|
||||
'pacman',
|
||||
'-S',
|
||||
'--needed',
|
||||
'--noconfirm',
|
||||
'base-devel',
|
||||
'python',
|
||||
'libsecret',
|
||||
@@ -443,7 +467,9 @@ async function installLinux() {
|
||||
}
|
||||
|
||||
const status = await runCommand('sudo', [
|
||||
'dnf', 'install', '-y',
|
||||
'dnf',
|
||||
'install',
|
||||
'-y',
|
||||
'gcc-c++',
|
||||
'make',
|
||||
'python3',
|
||||
@@ -471,7 +497,9 @@ async function installLinux() {
|
||||
console.log(' - libsecret development headers (for keytar)');
|
||||
console.log(' - pkg-config');
|
||||
console.log('');
|
||||
console.log(' Install them using your distribution package manager, then run npm install again.');
|
||||
console.log(
|
||||
' Install them using your distribution package manager, then run npm install again.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -520,7 +548,9 @@ async function main() {
|
||||
|
||||
// ── Missing tools ──
|
||||
console.log('');
|
||||
error('Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.');
|
||||
error(
|
||||
'Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.',
|
||||
);
|
||||
|
||||
if (checkOnly) {
|
||||
console.log('');
|
||||
@@ -528,7 +558,9 @@ async function main() {
|
||||
warn('Run "npm run setup" to install them automatically, or install manually:');
|
||||
printManualInstructions(platform, results.missing);
|
||||
console.log('');
|
||||
dim('npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.');
|
||||
dim(
|
||||
'npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.',
|
||||
);
|
||||
// Don't exit with error - allow npm install to succeed (prebuilt binaries may work)
|
||||
return;
|
||||
}
|
||||
@@ -572,13 +604,23 @@ function printManualInstructions(platform, missing) {
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
console.log(' 1. Download Visual Studio 2022 Build Tools:');
|
||||
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
|
||||
console.log(
|
||||
color(
|
||||
CYAN,
|
||||
' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022',
|
||||
),
|
||||
);
|
||||
console.log('');
|
||||
console.log(' 2. Run the installer and select:');
|
||||
console.log(color(BOLD, ' "Desktop development with C++"'));
|
||||
console.log('');
|
||||
console.log(' 3. Or install via PowerShell (as Administrator):');
|
||||
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
|
||||
console.log(
|
||||
color(
|
||||
DIM,
|
||||
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
|
||||
),
|
||||
);
|
||||
console.log('');
|
||||
console.log(' 4. Restart your terminal after installation.');
|
||||
break;
|
||||
@@ -594,13 +636,22 @@ function printManualInstructions(platform, missing) {
|
||||
const distro = detectLinuxDistro();
|
||||
if (distro === 'debian') {
|
||||
console.log(' Run:');
|
||||
console.log(color(DIM, ' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config'));
|
||||
console.log(
|
||||
color(
|
||||
DIM,
|
||||
' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config',
|
||||
),
|
||||
);
|
||||
} else if (distro === 'arch') {
|
||||
console.log(' Run:');
|
||||
console.log(color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'));
|
||||
console.log(
|
||||
color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'),
|
||||
);
|
||||
} else if (distro === 'fedora') {
|
||||
console.log(' Run:');
|
||||
console.log(color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'));
|
||||
console.log(
|
||||
color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'),
|
||||
);
|
||||
} else {
|
||||
console.log(' Install: C/C++ compiler, make, python3, libsecret-dev, pkg-config');
|
||||
}
|
||||
|
||||
@@ -114,8 +114,10 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden transition-all duration-200',
|
||||
|
||||
isCurrent ? 'border-primary/50 bg-primary/10' : 'hover:bg-accent/10 cursor-pointer border-white/5',
|
||||
|
||||
isCurrent
|
||||
? 'border-primary/50 bg-primary/10'
|
||||
: 'hover:bg-accent/10 cursor-pointer border-white/5',
|
||||
(isSwitching || isDeleting) && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
|
||||
|
||||
@@ -134,7 +134,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => { void refreshProfiles(); }, 0); }, [open, refreshProfiles]);
|
||||
setTimeout(() => {
|
||||
void refreshProfiles();
|
||||
}, 0);
|
||||
}, [open, refreshProfiles]);
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||
if (actionLockRef.current) {
|
||||
@@ -343,7 +346,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
|
||||
{!showLoadingPlaceholder
|
||||
? history.map((version) => (
|
||||
<div key={version.id} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
|
||||
<div
|
||||
key={version.id}
|
||||
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{version.label}</div>
|
||||
|
||||
@@ -48,8 +48,12 @@ export function ModelVisibilitySettings() {
|
||||
|
||||
// Initialize model visibility and provider groupings from config
|
||||
useEffect(() => {
|
||||
if (config?.model_visibility) { setTimeout(() => setModelVisibilityMap(config.model_visibility), 0); }
|
||||
if (config?.provider_groupings_enabled !== undefined) { setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0); }
|
||||
if (config?.model_visibility) {
|
||||
setTimeout(() => setModelVisibilityMap(config.model_visibility), 0);
|
||||
}
|
||||
if (config?.provider_groupings_enabled !== undefined) {
|
||||
setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0);
|
||||
}
|
||||
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
||||
|
||||
// Get all unique models from all accounts
|
||||
@@ -184,7 +188,7 @@ export function ModelVisibilitySettings() {
|
||||
return (
|
||||
<div
|
||||
key={modelId}
|
||||
className="flex items-center space-x-3 rounded-lg p-2 hover:bg-white/[0.03] transition-colors"
|
||||
className="flex items-center space-x-3 rounded-lg p-2 transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
<Checkbox
|
||||
id={`model-${modelId}`}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DragWindowRegion({ title }: DragWindowRegionProps) {
|
||||
<div className="flex w-screen items-stretch justify-between">
|
||||
<div className="draglayer w-full">
|
||||
{title && !isMacOS && (
|
||||
<div className="flex flex-1 p-2 text-xs whitespace-nowrap text-muted-foreground select-none">
|
||||
<div className="text-muted-foreground flex flex-1 p-2 text-xs whitespace-nowrap select-none">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -98,7 +98,7 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
<h3 className="text-base font-semibold">
|
||||
{t('proxy.advanced.capabilities.title', 'Model Capabilities')}
|
||||
</h3>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
@@ -121,11 +121,11 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
<div key={provider} className="space-y-3">
|
||||
{/* Provider header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-[13px] font-semibold tracking-wide uppercase text-muted-foreground">
|
||||
<h4 className="text-muted-foreground text-[13px] font-semibold tracking-wide uppercase">
|
||||
{provider}
|
||||
</h4>
|
||||
<div className="h-px flex-1 bg-white/[0.06]" />
|
||||
<span className="text-[11px] text-muted-foreground/60 tabular-nums">
|
||||
<span className="text-muted-foreground/60 text-[11px] tabular-nums">
|
||||
{groupedModels[provider].length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -140,23 +140,23 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="group flex flex-col rounded-xl border border-white/[0.06] bg-card p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
|
||||
className="group bg-card flex flex-col rounded-xl border border-white/[0.06] p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
|
||||
>
|
||||
{/* Model header */}
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h5
|
||||
className="text-[13px] font-semibold leading-tight truncate"
|
||||
className="truncate text-[13px] leading-tight font-semibold"
|
||||
title={model.displayName || model.id}
|
||||
>
|
||||
{model.displayName || model.id}
|
||||
</h5>
|
||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground/60 rounded-md bg-white/[0.04] px-1.5 py-0.5">
|
||||
<span className="text-muted-foreground/60 shrink-0 rounded-md bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{enabledCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-mono text-[11px] text-muted-foreground truncate"
|
||||
className="text-muted-foreground truncate font-mono text-[11px]"
|
||||
title={model.id}
|
||||
>
|
||||
{model.id}
|
||||
@@ -164,7 +164,7 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Capability pills */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{Object.entries(model.capabilities).map(([key, supported]) => {
|
||||
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
||||
const Icon = CAPABILITY_ICONS[iconKey];
|
||||
@@ -174,8 +174,8 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
key={key}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-colors ${
|
||||
supported
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/15'
|
||||
: 'bg-white/[0.02] text-muted-foreground/50 border border-white/[0.04]'
|
||||
? 'border border-emerald-500/15 bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-muted-foreground/50 border border-white/[0.04] bg-white/[0.02]'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
@@ -190,17 +190,17 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
{hasLimits && (
|
||||
<div className="mt-auto flex flex-wrap gap-1.5 border-t border-white/[0.04] pt-3">
|
||||
{model.limits.maxTokens != null && (
|
||||
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
|
||||
CTX {formatTokenCount(model.limits.maxTokens)}
|
||||
</span>
|
||||
)}
|
||||
{model.limits.maxOutputTokens != null && (
|
||||
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
|
||||
OUT {formatTokenCount(model.limits.maxOutputTokens)}
|
||||
</span>
|
||||
)}
|
||||
{model.limits.thinkingBudget != null && (
|
||||
<span className="inline-flex items-center rounded-md bg-blue-500/10 border border-blue-500/15 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
|
||||
<span className="inline-flex items-center rounded-md border border-blue-500/15 bg-blue-500/10 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
|
||||
THINK {formatTokenCount(model.limits.thinkingBudget)}
|
||||
</span>
|
||||
)}
|
||||
@@ -215,12 +215,10 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
|
||||
{models.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-white/[0.1] py-16 text-center">
|
||||
<Layers className="h-8 w-8 text-muted-foreground/30" />
|
||||
<Layers className="text-muted-foreground/30 h-8 w-8" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[13px] font-medium">No models found</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Check your provider configurations
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">Check your provider configurations</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { TrafficLogTable } from './TrafficLogTable';
|
||||
import { Loader2, Zap, Clock, AlertTriangle, Network, HardDrive } from 'lucide-react';
|
||||
|
||||
export const MonitorTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [direction, setDirection] = useState<string>('all');
|
||||
|
||||
const { data: metricsData, isLoading: metricsLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'metrics'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: cbData, isLoading: cbLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'circuitBreaker'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getCircuitBreakerStatus(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'logs', direction],
|
||||
queryFn: () =>
|
||||
ipc.client.proxyAdvanced.getRecentTrafficLogs({
|
||||
limit: 50,
|
||||
direction: direction === 'all' ? undefined : direction,
|
||||
}),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: statsData } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'stats'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getTrafficLogStats(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const metrics = metricsData?.success ? metricsData.data : undefined;
|
||||
const circuitBreakers = cbData?.success ? cbData.data : undefined;
|
||||
const logs = logsData?.success ? logsData.data : [];
|
||||
const stats = statsData?.success ? statsData.data : undefined;
|
||||
|
||||
const handleExport = async (format: 'json' | 'csv') => {
|
||||
const result = await ipc.client.proxyAdvanced.exportTrafficLogs({ format });
|
||||
if (result.success && result.data) {
|
||||
navigator.clipboard.writeText(result.data);
|
||||
toast({ title: t('proxy.advanced.tools.copyConfig') });
|
||||
} else {
|
||||
toast({ title: t('common.error'), description: result.error });
|
||||
}
|
||||
};
|
||||
|
||||
const getCircuitBadgeVariant = (state: string): React.ComponentProps<typeof Badge>['variant'] => {
|
||||
if (state === 'closed') return 'default';
|
||||
if (state === 'open') return 'destructive';
|
||||
return 'secondary';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.requestsPerMinute')}
|
||||
value={metrics ? Math.round(metrics.requestsPerMinute) : 0}
|
||||
icon={Zap}
|
||||
isLoading={metricsLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.avgLatency')}
|
||||
value={metrics ? Math.round(metrics.avgLatency) : 0}
|
||||
icon={Clock}
|
||||
isLoading={metricsLoading}
|
||||
accent="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.errorRate')}
|
||||
value={metrics ? Math.round(metrics.errorRate * 100) : 0}
|
||||
icon={AlertTriangle}
|
||||
isLoading={metricsLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.activeConnections')}
|
||||
value={metrics?.activeConnections ?? 0}
|
||||
icon={Network}
|
||||
isLoading={metricsLoading}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.cacheStatus')}
|
||||
value={metrics?.cacheHitRate ? `${Math.round(metrics.cacheHitRate * 100)}%` : 'N/A'}
|
||||
icon={HardDrive}
|
||||
isLoading={metricsLoading}
|
||||
accent="purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Circuit Breakers */}
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h4 className="text-[13px] font-semibold">
|
||||
{t('proxy.advanced.circuitBreaker.title')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{cbLoading ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !circuitBreakers ? (
|
||||
<p className="text-[13px] text-muted-foreground">No data</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.entries(circuitBreakers.states).map(([api, state]) => (
|
||||
<div key={api} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] font-medium">{api}</span>
|
||||
<Badge variant={getCircuitBadgeVariant(state.state)}>
|
||||
{state.state}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
|
||||
</div>
|
||||
{state.lastFailure && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
|
||||
{new Date(state.lastFailure).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic Logs */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-[13px] font-semibold">
|
||||
{t('proxy.advanced.trafficLog.title')}
|
||||
</h3>
|
||||
{stats && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Total: {stats.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={direction} onValueChange={setDirection}>
|
||||
<SelectTrigger className="h-8 w-[140px] text-xs">
|
||||
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('proxy.advanced.trafficLog.all')}
|
||||
</SelectItem>
|
||||
<SelectItem value="inbound">
|
||||
{t('proxy.advanced.trafficLog.inbound')}
|
||||
</SelectItem>
|
||||
<SelectItem value="outbound">
|
||||
{t('proxy.advanced.trafficLog.outbound')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleExport('json')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.exportJson')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleExport('csv')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.exportCsv')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TrafficLogTable logs={logs || []} isLoading={logsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -54,7 +54,8 @@ export const ReplayTab: React.FC = () => {
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('proxy.advanced.replay.error'),
|
||||
description: error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
setSelectedRequestId(null);
|
||||
@@ -76,7 +77,10 @@ export const ReplayTab: React.FC = () => {
|
||||
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
|
||||
if (status >= 200 && status < 300) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/20">
|
||||
<Badge
|
||||
variant="default"
|
||||
className="border-emerald-500/20 bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
{status}
|
||||
</Badge>
|
||||
@@ -99,7 +103,9 @@ export const ReplayTab: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{t('proxy.advanced.replay.title')}</h3>
|
||||
<p className="text-[13px] text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
{t('proxy.advanced.replay.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -113,10 +119,12 @@ export const ReplayTab: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Requests List */}
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
|
||||
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div className="space-y-1 border-b border-white/[0.06] px-5 py-4">
|
||||
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.replay.recentRequests')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('proxy.advanced.replay.clickToReplay')}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('proxy.advanced.replay.clickToReplay')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{requestsLoading ? (
|
||||
@@ -125,8 +133,8 @@ export const ReplayTab: React.FC = () => {
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-white/[0.1]">
|
||||
<Clock className="h-6 w-6 text-muted-foreground/30" />
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<Clock className="text-muted-foreground/30 h-6 w-6" />
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
{t('proxy.advanced.replay.noRequests')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -143,7 +151,7 @@ export const ReplayTab: React.FC = () => {
|
||||
<span className="text-[13px] font-medium">
|
||||
{request.method} {request.endpoint}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatTimestamp(request.timestamp)}
|
||||
{request.duration && ` · ${request.duration}ms`}
|
||||
</span>
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface TrafficLogEntry {
|
||||
timestamp: string;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface TrafficLogTableProps {
|
||||
logs: TrafficLogEntry[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'method' | 'status' | 'duration';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function SortIcon({ column, sortKey, sortDir }: { column: SortKey; sortKey: SortKey; sortDir: SortDir }) {
|
||||
if (sortKey !== column) return <span className="ml-1 inline-block w-3" />;
|
||||
return sortDir === 'asc' ? (
|
||||
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||
);
|
||||
}
|
||||
|
||||
export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedLogs = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...logs].sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'timestamp':
|
||||
return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) * dir;
|
||||
case 'direction':
|
||||
return (a.direction || '').localeCompare(b.direction || '') * dir;
|
||||
case 'endpoint':
|
||||
return (a.endpoint || '').localeCompare(b.endpoint || '') * dir;
|
||||
case 'method':
|
||||
return (a.method || '').localeCompare(b.method || '') * dir;
|
||||
case 'status': {
|
||||
const aStatus = a.status ?? -1;
|
||||
const bStatus = b.status ?? -1;
|
||||
return (aStatus - bStatus) * dir;
|
||||
}
|
||||
case 'duration': {
|
||||
const aDur = a.durationMs ?? -1;
|
||||
const bDur = b.durationMs ?? -1;
|
||||
return (aDur - bDur) * dir;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [logs, sortKey, sortDir]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h4 className="text-[13px] font-semibold">
|
||||
{t('proxy.advanced.trafficLog.title')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="py-8 text-center text-[13px] text-muted-foreground">
|
||||
{t('proxy.advanced.trafficLog.noLogs')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-[10px] font-medium tracking-wider uppercase text-muted-foreground">
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('timestamp')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.timestamp')}
|
||||
<SortIcon column="timestamp" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('direction')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.direction')}
|
||||
<SortIcon column="direction" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('endpoint')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.endpoint')}
|
||||
<SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('method')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.method')}
|
||||
<SortIcon column="method" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('status')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.status')}
|
||||
<SortIcon column="status" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 font-medium select-none"
|
||||
onClick={() => toggleSort('duration')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.duration')}
|
||||
<SortIcon column="duration" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedLogs.map((log, i) => (
|
||||
<tr
|
||||
key={`${log.requestId}-${i}`}
|
||||
className="border-b border-white/[0.04] last:border-0"
|
||||
>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
log.direction === 'inbound'
|
||||
? 'border-blue-500/30 text-blue-400'
|
||||
: log.direction === 'outbound'
|
||||
? 'border-emerald-500/30 text-emerald-400'
|
||||
: 'border-slate-500/30 text-slate-400'
|
||||
}
|
||||
>
|
||||
{log.direction}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{log.endpoint}</td>
|
||||
<td className="py-2 pr-4 text-xs">{log.method || '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{log.status != null ? (
|
||||
<Badge
|
||||
variant={log.status >= 400 ? 'destructive' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{log.status}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,16 +9,14 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
||||
destructive:
|
||||
'bg-destructive/90 text-white hover:bg-destructive active:scale-[0.98] focus-visible:ring-destructive/30',
|
||||
outline:
|
||||
'border border-white/[0.08] bg-transparent hover:bg-white/[0.04] hover:border-white/[0.12] text-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
||||
ghost:
|
||||
'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
|
||||
ghost: 'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
|
||||
link: 'text-accent underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
|
||||
@@ -26,7 +26,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold leading-none tracking-tight', className)}
|
||||
className={cn('text-sm leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent ring-offset-background transition-colors focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background',
|
||||
'peer ring-offset-background focus-visible:ring-accent/30 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||
'gap-4 rounded-xl border border-white/[0.08] bg-card p-6 shadow-2xl shadow-black/30',
|
||||
'bg-card gap-4 rounded-xl border border-white/[0.08] p-6 shadow-2xl shadow-black/30',
|
||||
'duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
@@ -47,7 +47,7 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring/30 disabled:pointer-events-none">
|
||||
<DialogPrimitive.Close className="focus:ring-ring/30 absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:ring-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] outline-none select-none transition-colors focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
|
||||
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
@@ -92,7 +92,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'focus:text-foreground relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
@@ -108,7 +108,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -131,7 +131,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -154,7 +154,11 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
||||
className={cn(
|
||||
'text-muted-foreground px-2 py-1.5 text-xs font-medium',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -8,10 +8,10 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-1 text-[13px] text-foreground shadow-none transition-colors duration-200',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
||||
'bg-muted/50 text-foreground flex h-9 w-full rounded-lg border border-white/[0.08] px-3 py-1 text-[13px] shadow-none transition-colors duration-200',
|
||||
'file:text-foreground file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:border-accent/50 focus-visible:ring-1 focus-visible:ring-accent/20',
|
||||
'focus-visible:border-accent/50 focus-visible:ring-accent/20 focus-visible:ring-1 focus-visible:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -19,9 +19,9 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-2 text-[13px] text-foreground transition-colors duration-200',
|
||||
'bg-muted/50 text-foreground flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] px-3 py-2 text-[13px] transition-colors duration-200',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20',
|
||||
'focus:border-accent/50 focus:ring-accent/20 focus:ring-1 focus:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className,
|
||||
@@ -72,7 +72,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] bg-popover text-popover-foreground shadow-xl shadow-black/20',
|
||||
'bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] shadow-xl shadow-black/20',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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',
|
||||
position === 'popper' &&
|
||||
@@ -104,7 +104,7 @@ const SelectLabel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pr-2 pl-8 text-xs font-medium text-muted-foreground', className)}
|
||||
className={cn('text-muted-foreground py-1.5 pr-2 pl-8 text-xs font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -117,8 +117,8 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] outline-none select-none transition-colors',
|
||||
'focus:bg-white/[0.06] focus:text-foreground',
|
||||
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none',
|
||||
'focus:text-foreground focus:bg-white/[0.06]',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
@@ -126,7 +126,7 @@ const SelectItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-3.5 w-3.5 text-accent" />
|
||||
<Check className="text-accent h-3.5 w-3.5" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-none transition-colors duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'focus-visible:ring-ring/30 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-white/[0.1]',
|
||||
className,
|
||||
|
||||
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1 border border-white/[0.04]',
|
||||
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg border border-white/[0.04] p-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -28,8 +28,8 @@ const TabsTrigger = React.forwardRef<
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md px-3 py-1 text-[13px] font-medium whitespace-nowrap transition-all duration-200',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-white/[0.08]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
||||
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:border data-[state=active]:border-white/[0.08] data-[state=active]:shadow-sm',
|
||||
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
@@ -45,7 +45,7 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-3 animate-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
||||
'animate-in focus-visible:ring-ring/30 mt-3 focus-visible:ring-2 focus-visible:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-lg border border-white/[0.08] bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg shadow-black/20',
|
||||
'bg-popover text-popover-foreground z-50 overflow-hidden rounded-lg border border-white/[0.08] px-3 py-1.5 text-xs shadow-lg shadow-black/20',
|
||||
'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',
|
||||
|
||||
@@ -685,7 +685,7 @@ export async function setAutoSwitchEnabled(enabled: boolean): Promise<void> {
|
||||
// Trigger an immediate check if enabled
|
||||
if (enabled) {
|
||||
const { CloudMonitorService } = await import('../../services/CloudMonitorService');
|
||||
CloudMonitorService.poll().catch((err: unknown) =>
|
||||
CloudMonitorService.poll().catch((err: unknown) =>
|
||||
logger.error('Failed to poll after enabling auto-switch', err),
|
||||
);
|
||||
}
|
||||
|
||||
+20
-4
@@ -193,7 +193,11 @@ export const cloudRouter = os.router({
|
||||
try {
|
||||
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
logger.error(
|
||||
'[ORPC] setAccountProxy error:',
|
||||
extractErrorMessage(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
@@ -204,7 +208,11 @@ export const cloudRouter = os.router({
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
logger.error(
|
||||
'[ORPC] syncLocalAccount error:',
|
||||
extractErrorMessage(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw toSyncLocalAccountORPCError(error);
|
||||
}
|
||||
}),
|
||||
@@ -274,7 +282,11 @@ export const cloudRouter = os.router({
|
||||
try {
|
||||
return await exportCloudAccounts(input.stripTokens);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
logger.error(
|
||||
'[ORPC] exportCloudAccounts error:',
|
||||
extractErrorMessage(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
@@ -298,7 +310,11 @@ export const cloudRouter = os.router({
|
||||
try {
|
||||
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
logger.error(
|
||||
'[ORPC] importCloudAccounts error:',
|
||||
extractErrorMessage(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
bootstrapNestServer,
|
||||
isNestServerRunning,
|
||||
stopNestServer,
|
||||
} from '../../server/main';
|
||||
import { bootstrapNestServer, isNestServerRunning, stopNestServer } from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
|
||||
/**
|
||||
@@ -25,8 +21,6 @@ export async function closeGeminiNexus(): Promise<void> {
|
||||
await stopNestServer();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Waits for the Gemini Nexus process to exit.
|
||||
* Polls the NestJS server status until it stops or the timeout is reached.
|
||||
|
||||
@@ -62,7 +62,9 @@ export class QuotaService {
|
||||
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.error(
|
||||
`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return [undefined, undefined];
|
||||
@@ -174,7 +176,9 @@ export class QuotaService {
|
||||
lastError = new Error(`HTTP ${status} - ${text}`);
|
||||
shouldFallback = !isNumber(status);
|
||||
} else {
|
||||
logger.warn(`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.warn(
|
||||
`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
@@ -182,7 +186,12 @@ export class QuotaService {
|
||||
logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
} else {
|
||||
throw lastError ?? new Error(`Quota query failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw (
|
||||
lastError ??
|
||||
new Error(
|
||||
`Quota query failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-11
@@ -92,7 +92,7 @@ function SettingsPage() {
|
||||
|
||||
{/* --- GENERAL TAB --- */}
|
||||
<TabsContent value="general" className="space-y-5">
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.appearance.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.appearance.description')}</CardDescription>
|
||||
@@ -139,7 +139,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Account Settings Card */}
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.account.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.account.description')}</CardDescription>
|
||||
@@ -149,7 +149,9 @@ function SettingsPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.account.auto_refresh')}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t('settings.account.auto_refresh_desc')}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.account.auto_refresh_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_refresh || false}
|
||||
@@ -165,7 +167,9 @@ function SettingsPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.account.auto_sync')}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t('settings.account.auto_sync_desc')}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.account.auto_sync_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_sync || false}
|
||||
@@ -180,7 +184,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{isAutoStartSupported && (
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.startup.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.startup.description')}</CardDescription>
|
||||
@@ -211,7 +215,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.about.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.about.description')}</CardDescription>
|
||||
@@ -230,7 +234,7 @@ function SettingsPage() {
|
||||
<div className="text-muted-foreground">{t('action.openLogs')}</div>
|
||||
<button
|
||||
onClick={() => openLogDirectory()}
|
||||
className="flex items-center gap-2 font-medium text-accent hover:text-accent/80 transition-colors"
|
||||
className="text-accent hover:text-accent/80 flex items-center gap-2 font-medium transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span>{t('settings.openLogDir')}</span>
|
||||
@@ -240,7 +244,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Privacy & Error Reporting Card */}
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.privacy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.privacy.description')}</CardDescription>
|
||||
@@ -267,7 +271,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Notifications Card */}
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.notifications.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.notifications.description')}</CardDescription>
|
||||
@@ -327,7 +331,7 @@ function SettingsPage() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-16 rounded-lg border border-white/[0.08] bg-muted/50 px-2 py-1 text-center text-[13px] text-foreground focus:outline-none focus:border-accent/50"
|
||||
className="bg-muted/50 text-foreground focus:border-accent/50 w-16 rounded-lg border border-white/[0.08] px-2 py-1 text-center text-[13px] focus:outline-none"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">%</span>
|
||||
</div>
|
||||
@@ -343,7 +347,7 @@ function SettingsPage() {
|
||||
|
||||
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
||||
<TabsContent value="proxy" className="space-y-5">
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.proxy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.proxy.description')}</CardDescription>
|
||||
|
||||
@@ -25,9 +25,7 @@ export class ProxyReplayService {
|
||||
private readonly buffer: ReplayEntry[] = [];
|
||||
private readonly maxSize = 20;
|
||||
|
||||
constructor(
|
||||
@Inject(ProxyService) private readonly proxyService: ProxyService,
|
||||
) {
|
||||
constructor(@Inject(ProxyService) private readonly proxyService: ProxyService) {
|
||||
registerProxyAdvancedService('proxyReplay', this);
|
||||
}
|
||||
|
||||
|
||||
@@ -349,14 +349,23 @@ export class TokenManagerService implements OnModuleInit {
|
||||
if (requestedAccountId && !excludedAccountIds.has(requestedAccountId)) {
|
||||
const requestedToken = this.tokens.get(requestedAccountId);
|
||||
if (requestedToken) {
|
||||
const isHealthy = this.isAccountHealthy(requestedAccountId, requestedToken, nowSeconds, model);
|
||||
const isHealthy = this.isAccountHealthy(
|
||||
requestedAccountId,
|
||||
requestedToken,
|
||||
nowSeconds,
|
||||
model,
|
||||
);
|
||||
if (isHealthy) {
|
||||
this.logger.log(`Using requested account: ${requestedAccountId}`);
|
||||
return this.buildCloudAccount(requestedAccountId, requestedToken);
|
||||
}
|
||||
this.logger.warn(`Requested account ${requestedAccountId} is not healthy, falling back to selection`);
|
||||
this.logger.warn(
|
||||
`Requested account ${requestedAccountId} is not healthy, falling back to selection`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(`Requested account ${requestedAccountId} not found, falling back to selection`);
|
||||
this.logger.warn(
|
||||
`Requested account ${requestedAccountId} not found, falling back to selection`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,7 +971,12 @@ export class TokenManagerService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private isAccountHealthy(accountId: string, tokenData: TokenData, nowSeconds: number, model?: string): boolean {
|
||||
private isAccountHealthy(
|
||||
accountId: string,
|
||||
tokenData: TokenData,
|
||||
nowSeconds: number,
|
||||
model?: string,
|
||||
): boolean {
|
||||
const entry = this.getOrCreateCircuitBreakerEntry(accountId);
|
||||
if (entry.state === 'unhealthy') {
|
||||
return false;
|
||||
@@ -1089,8 +1103,24 @@ export class TokenManagerService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
getCircuitBreakerStatus(): Record<string, { state: 'healthy' | 'degraded' | 'unhealthy'; failureCount: number; lastFailureTime: number | null; failureRate: number }> {
|
||||
const result: Record<string, { state: 'healthy' | 'degraded' | 'unhealthy'; failureCount: number; lastFailureTime: number | null; failureRate: number }> = {};
|
||||
getCircuitBreakerStatus(): Record<
|
||||
string,
|
||||
{
|
||||
state: 'healthy' | 'degraded' | 'unhealthy';
|
||||
failureCount: number;
|
||||
lastFailureTime: number | null;
|
||||
failureRate: number;
|
||||
}
|
||||
> {
|
||||
const result: Record<
|
||||
string,
|
||||
{
|
||||
state: 'healthy' | 'degraded' | 'unhealthy';
|
||||
failureCount: number;
|
||||
lastFailureTime: number | null;
|
||||
failureRate: number;
|
||||
}
|
||||
> = {};
|
||||
for (const accountId of this.tokens.keys()) {
|
||||
const entry = this.getOrCreateCircuitBreakerEntry(accountId);
|
||||
this.computeCircuitBreakerState(entry);
|
||||
@@ -1104,7 +1134,12 @@ export class TokenManagerService implements OnModuleInit {
|
||||
return result;
|
||||
}
|
||||
|
||||
getParityCounters(): { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string } {
|
||||
getParityCounters(): {
|
||||
totalRequests: number;
|
||||
matchedRequests: number;
|
||||
parityViolations: number;
|
||||
lastUpdated: string;
|
||||
} {
|
||||
return {
|
||||
totalRequests: this.parityRequestCount,
|
||||
matchedRequests: this.shadowComparisonCount,
|
||||
|
||||
+37
-11
@@ -65,24 +65,48 @@
|
||||
--animate-scale-in: scale-in 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from { height: 0; }
|
||||
to { height: var(--radix-accordion-content-height); }
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from { height: var(--radix-accordion-content-height); }
|
||||
to { height: 0; }
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes scale-in {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +282,8 @@
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fade-in 0.2s ease-out, slide-up 0.25s ease-out;
|
||||
animation:
|
||||
fade-in 0.2s ease-out,
|
||||
slide-up 0.25s ease-out;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
{
|
||||
"contains": [
|
||||
"chat.completion.chunk",
|
||||
"Hello world",
|
||||
"finish_reason",
|
||||
"data: [DONE]"
|
||||
]
|
||||
"contains": ["chat.completion.chunk", "Hello world", "finish_reason", "data: [DONE]"]
|
||||
}
|
||||
@@ -22,7 +22,13 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => {
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
asChild,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}) => {
|
||||
if (asChild) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -40,12 +46,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button
|
||||
data-testid="dropdown-item"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
<button data-testid="dropdown-item" onClick={onClick} disabled={disabled} type="button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
@@ -101,10 +102,7 @@ describe('AccountCard', () => {
|
||||
|
||||
it('shows avatar fallback with initials when avatar_url is missing', () => {
|
||||
renderWithProviders(
|
||||
<AccountCard
|
||||
{...defaultProps}
|
||||
account={createMockAccount({ avatar_url: undefined })}
|
||||
/>,
|
||||
<AccountCard {...defaultProps} account={createMockAccount({ avatar_url: undefined })} />,
|
||||
);
|
||||
expect(screen.getByText('TU')).toBeInTheDocument();
|
||||
});
|
||||
@@ -154,9 +152,7 @@ describe('AccountCard', () => {
|
||||
|
||||
it('does not trigger switch when isCurrent is true', () => {
|
||||
const onSwitch = vi.fn();
|
||||
renderWithProviders(
|
||||
<AccountCard {...defaultProps} isCurrent={true} onSwitch={onSwitch} />,
|
||||
);
|
||||
renderWithProviders(<AccountCard {...defaultProps} isCurrent={true} onSwitch={onSwitch} />);
|
||||
|
||||
const card = document.querySelector('[class*="cursor-pointer"]');
|
||||
expect(card).not.toBeInTheDocument();
|
||||
@@ -165,9 +161,7 @@ describe('AccountCard', () => {
|
||||
|
||||
it('does not trigger switch when isSwitching is true', () => {
|
||||
const onSwitch = vi.fn();
|
||||
renderWithProviders(
|
||||
<AccountCard {...defaultProps} isSwitching={true} onSwitch={onSwitch} />,
|
||||
);
|
||||
renderWithProviders(<AccountCard {...defaultProps} isSwitching={true} onSwitch={onSwitch} />);
|
||||
|
||||
const card = document.querySelector('[class*="pointer-events-none"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
@@ -175,34 +169,26 @@ describe('AccountCard', () => {
|
||||
|
||||
it('does not trigger switch when isDeleting is true', () => {
|
||||
const onSwitch = vi.fn();
|
||||
renderWithProviders(
|
||||
<AccountCard {...defaultProps} isDeleting={true} onSwitch={onSwitch} />,
|
||||
);
|
||||
renderWithProviders(<AccountCard {...defaultProps} isDeleting={true} onSwitch={onSwitch} />);
|
||||
|
||||
const card = document.querySelector('[class*="pointer-events-none"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies reduced opacity and pointer-events-none when switching', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AccountCard {...defaultProps} isSwitching={true} />,
|
||||
);
|
||||
const { container } = renderWithProviders(<AccountCard {...defaultProps} isSwitching={true} />);
|
||||
const card = container.querySelector('[class*="opacity-60"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies reduced opacity and pointer-events-none when deleting', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AccountCard {...defaultProps} isDeleting={true} />,
|
||||
);
|
||||
const { container } = renderWithProviders(<AccountCard {...defaultProps} isDeleting={true} />);
|
||||
const card = container.querySelector('[class*="opacity-60"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies primary border styling when isCurrent is true', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AccountCard {...defaultProps} isCurrent={true} />,
|
||||
);
|
||||
const { container } = renderWithProviders(<AccountCard {...defaultProps} isCurrent={true} />);
|
||||
const card = container.querySelector('[class*="border-primary/50"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -58,9 +58,7 @@ describe('ProviderGroup', () => {
|
||||
|
||||
it('displays model count', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText(/settings\.providerGroupings\.models/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/settings\.providerGroupings\.models/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows quota bars and average percentage', () => {
|
||||
@@ -88,9 +86,7 @@ describe('ProviderGroup', () => {
|
||||
|
||||
it('calls onToggleCollapse when header button is clicked', () => {
|
||||
const onToggleCollapse = vi.fn();
|
||||
renderWithProviders(
|
||||
<ProviderGroup {...defaultProps} onToggleCollapse={onToggleCollapse} />,
|
||||
);
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} onToggleCollapse={onToggleCollapse} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
@@ -98,9 +94,7 @@ describe('ProviderGroup', () => {
|
||||
});
|
||||
|
||||
it('renders color dot with provider color', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<ProviderGroup {...defaultProps} />,
|
||||
);
|
||||
const { container } = renderWithProviders(<ProviderGroup {...defaultProps} />);
|
||||
const dot = container.querySelector('span[style*="background-color"]');
|
||||
expect(dot).toHaveAttribute('style', expect.stringContaining('#4285F4'));
|
||||
});
|
||||
@@ -117,10 +111,7 @@ describe('ProviderGroup', () => {
|
||||
|
||||
it('renders nothing when visibleModels is empty', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<ProviderGroup
|
||||
{...defaultProps}
|
||||
stats={createMockStats({ visibleModels: [] })}
|
||||
/>,
|
||||
<ProviderGroup {...defaultProps} stats={createMockStats({ visibleModels: [] })} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
@@ -13,9 +13,7 @@ describe('Button', () => {
|
||||
it('renders all variant styles', () => {
|
||||
const variants = ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'] as const;
|
||||
variants.forEach((variant) => {
|
||||
const { container } = renderWithProviders(
|
||||
<Button variant={variant}>{variant}</Button>,
|
||||
);
|
||||
const { container } = renderWithProviders(<Button variant={variant}>{variant}</Button>);
|
||||
expect(container.querySelector('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -23,9 +21,7 @@ describe('Button', () => {
|
||||
it('renders all size styles', () => {
|
||||
const sizes = ['default', 'sm', 'lg', 'icon'] as const;
|
||||
sizes.forEach((size) => {
|
||||
const { container } = renderWithProviders(
|
||||
<Button size={size}>{size}</Button>,
|
||||
);
|
||||
const { container } = renderWithProviders(<Button size={size}>{size}</Button>);
|
||||
expect(container.querySelector('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,9 +18,7 @@ describe('ChartCard', () => {
|
||||
};
|
||||
|
||||
it('renders loading state with skeleton', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard {...defaultProps} isLoading={true} />,
|
||||
);
|
||||
renderWithProviders(<ChartCard {...defaultProps} isLoading={true} />);
|
||||
|
||||
expect(screen.getByText('Test Chart')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
@@ -30,11 +28,7 @@ describe('ChartCard', () => {
|
||||
it('renders error state with retry button and calls onRetry when clicked', () => {
|
||||
const onRetry = vi.fn();
|
||||
renderWithProviders(
|
||||
<ChartCard
|
||||
{...defaultProps}
|
||||
error={new Error('Network error')}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
<ChartCard {...defaultProps} error={new Error('Network error')} onRetry={onRetry} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Failed to load chart.')).toBeInTheDocument();
|
||||
@@ -46,14 +40,10 @@ describe('ChartCard', () => {
|
||||
});
|
||||
|
||||
it('renders empty state with title and description', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard {...defaultProps} isEmpty={true} />,
|
||||
);
|
||||
renderWithProviders(<ChartCard {...defaultProps} isEmpty={true} />);
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('There is no data to display.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('There is no data to display.')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chart-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -67,11 +57,7 @@ describe('ChartCard', () => {
|
||||
|
||||
it('prioritizes loading over error state', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard
|
||||
{...defaultProps}
|
||||
isLoading={true}
|
||||
error={new Error('Some error')}
|
||||
/>,
|
||||
<ChartCard {...defaultProps} isLoading={true} error={new Error('Some error')} />,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
@@ -80,11 +66,7 @@ describe('ChartCard', () => {
|
||||
|
||||
it('prioritizes error over empty state', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard
|
||||
{...defaultProps}
|
||||
error={new Error('Some error')}
|
||||
isEmpty={true}
|
||||
/>,
|
||||
<ChartCard {...defaultProps} error={new Error('Some error')} isEmpty={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Failed to load chart.')).toBeInTheDocument();
|
||||
|
||||
@@ -15,54 +15,40 @@ describe('StatCard', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it('renders label and value', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Total Tokens" value={12345} icon={Activity} />,
|
||||
);
|
||||
renderWithProviders(<StatCard label="Total Tokens" value={12345} icon={Activity} />);
|
||||
expect(screen.getByText('Total Tokens')).toBeInTheDocument();
|
||||
expect(screen.getByText('12,345')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders string values correctly', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Status" value="Active" icon={Zap} />,
|
||||
);
|
||||
renderWithProviders(<StatCard label="Status" value="Active" icon={Zap} />);
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats number values with locale string', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Requests" value={1000000} icon={TrendingUp} />,
|
||||
);
|
||||
renderWithProviders(<StatCard label="Requests" value={1000000} icon={TrendingUp} />);
|
||||
expect(screen.getByText('1,000,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows skeleton when isLoading is true', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Loading" value={0} icon={Users} isLoading />,
|
||||
);
|
||||
renderWithProviders(<StatCard label="Loading" value={0} icon={Users} isLoading />);
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show skeleton when isLoading is false', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Loaded" value={42} icon={Activity} isLoading={false} />,
|
||||
);
|
||||
renderWithProviders(<StatCard label="Loaded" value={42} icon={Activity} isLoading={false} />);
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-pulse')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon element', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="With Icon" value={100} icon={Zap} />,
|
||||
);
|
||||
renderWithProviders(<StatCard label="With Icon" value={100} icon={Zap} />);
|
||||
expect(document.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Styled" value={1} icon={Activity} className="my-stat" />,
|
||||
);
|
||||
renderWithProviders(<StatCard label="Styled" value={1} icon={Activity} className="my-stat" />);
|
||||
// The wrapper div uses rounded-xl, find it by that selector
|
||||
const wrapper = screen.getByText('Styled').closest('.rounded-xl');
|
||||
expect(wrapper).toHaveClass('my-stat');
|
||||
@@ -109,17 +95,13 @@ describe('StatCard', () => {
|
||||
});
|
||||
|
||||
it('has uppercase label styling', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Uppercase Test" value={1} icon={Activity} />,
|
||||
);
|
||||
renderWithProviders(<StatCard label="Uppercase Test" value={1} icon={Activity} />);
|
||||
const label = screen.getByText('Uppercase Test');
|
||||
expect(label).toHaveClass('uppercase');
|
||||
});
|
||||
|
||||
it('has mono font for value', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Mono" value={999} icon={Activity} />,
|
||||
);
|
||||
renderWithProviders(<StatCard label="Mono" value={999} icon={Activity} />);
|
||||
const value = screen.getByText('999');
|
||||
expect(value).toHaveClass('font-mono');
|
||||
});
|
||||
|
||||
@@ -121,7 +121,9 @@ describe('useCloudAccounts hooks', () => {
|
||||
];
|
||||
|
||||
options?.onSuccess?.(updatedAccount);
|
||||
const updater = mockSetQueryData.mock.calls[0][1] as (prev: CloudAccount[] | undefined) => CloudAccount[];
|
||||
const updater = mockSetQueryData.mock.calls[0][1] as (
|
||||
prev: CloudAccount[] | undefined,
|
||||
) => CloudAccount[];
|
||||
const result = updater(oldData);
|
||||
expect(result).toEqual([oldData[0], updatedAccount]);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
@@ -133,7 +135,9 @@ describe('useCloudAccounts hooks', () => {
|
||||
const updatedAccount: CloudAccount = { id: 'acc-1' } as CloudAccount;
|
||||
|
||||
options?.onSuccess?.(updatedAccount);
|
||||
const updater = mockSetQueryData.mock.calls[0][1] as (prev: CloudAccount[] | undefined) => CloudAccount[];
|
||||
const updater = mockSetQueryData.mock.calls[0][1] as (
|
||||
prev: CloudAccount[] | undefined,
|
||||
) => CloudAccount[];
|
||||
const result = updater(undefined);
|
||||
expect(result).toEqual([updatedAccount]);
|
||||
});
|
||||
|
||||
@@ -53,10 +53,13 @@ vi.mock('../../../utils/paths', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../ipc/database/dbConnection', () => ({
|
||||
openDrizzleConnection: vi.fn().mockImplementation(() => ({
|
||||
raw: { close: mockClose },
|
||||
orm: createMockOrm(),
|
||||
}) as any),
|
||||
openDrizzleConnection: vi.fn().mockImplementation(
|
||||
() =>
|
||||
({
|
||||
raw: { close: mockClose },
|
||||
orm: createMockOrm(),
|
||||
}) as any,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
@@ -84,10 +87,13 @@ describe('Database Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getGeminiNexusDbPaths).mockReturnValue(['/tmp/test.vscdb']);
|
||||
vi.mocked(openDrizzleConnection).mockImplementation(() => ({
|
||||
raw: { close: mockClose },
|
||||
orm: createMockOrm(),
|
||||
}) as any);
|
||||
vi.mocked(openDrizzleConnection).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
raw: { close: mockClose },
|
||||
orm: createMockOrm(),
|
||||
}) as any,
|
||||
);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
});
|
||||
|
||||
@@ -127,7 +133,9 @@ describe('Database Handler', () => {
|
||||
err.code = 'SQLITE_BUSY';
|
||||
throw err;
|
||||
});
|
||||
expect(() => getDatabaseConnection()).toThrow('Database is locked. Please close Gemini Nexus before proceeding.');
|
||||
expect(() => getDatabaseConnection()).toThrow(
|
||||
'Database is locked. Please close Gemini Nexus before proceeding.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw SQLITE_LOCKED error as user-friendly message', () => {
|
||||
@@ -136,7 +144,9 @@ describe('Database Handler', () => {
|
||||
err.code = 'SQLITE_LOCKED';
|
||||
throw err;
|
||||
});
|
||||
expect(() => getDatabaseConnection()).toThrow('Database is locked. Please close Gemini Nexus before proceeding.');
|
||||
expect(() => getDatabaseConnection()).toThrow(
|
||||
'Database is locked. Please close Gemini Nexus before proceeding.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -99,9 +99,15 @@ describe('Device Handler', () => {
|
||||
expect(profile).toHaveProperty('devDeviceId');
|
||||
expect(profile).toHaveProperty('sqmId');
|
||||
expect(profile.machineId).toMatch(/^auth0\|user_[0-9a-f]{32}$/);
|
||||
expect(profile.macMachineId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(profile.devDeviceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(profile.sqmId).toMatch(/^\{[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\}$/);
|
||||
expect(profile.macMachineId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(profile.devDeviceId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(profile.sqmId).toMatch(
|
||||
/^\{[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\}$/,
|
||||
);
|
||||
expect(profile.macMachineId).not.toBe(profile.devDeviceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,12 +146,22 @@ describe('Proxy Advanced Handler', () => {
|
||||
|
||||
it('should export as CSV', async () => {
|
||||
vi.mocked(TrafficLogsRepo.getAllForExport).mockReturnValueOnce([
|
||||
{ timestamp: 1000, direction: 'outbound', requestId: 'r1', endpoint: '/chat', method: 'POST', status: 200, durationMs: 50 },
|
||||
{
|
||||
timestamp: 1000,
|
||||
direction: 'outbound',
|
||||
requestId: 'r1',
|
||||
endpoint: '/chat',
|
||||
method: 'POST',
|
||||
status: 200,
|
||||
durationMs: 50,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await exportTrafficLogs('csv');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toContain('timestamp,direction,requestId,endpoint,method,status,durationMs');
|
||||
expect(result.data).toContain(
|
||||
'timestamp,direction,requestId,endpoint,method,status,durationMs',
|
||||
);
|
||||
expect(result.data).toContain('outbound');
|
||||
});
|
||||
|
||||
|
||||
@@ -30,17 +30,13 @@ export class MockDatabase {
|
||||
const values = params.length === 1 && typeof params[0] === 'object' ? params[0] : params;
|
||||
const rows = this.data.get(table) || [];
|
||||
if (!values || (Array.isArray(values) && values.length === 0)) return rows[0] ?? null;
|
||||
return rows.find((row) =>
|
||||
Object.keys(values).every((key) => row[key] === values[key]),
|
||||
);
|
||||
return rows.find((row) => Object.keys(values).every((key) => row[key] === values[key]));
|
||||
}),
|
||||
all: vi.fn().mockImplementation((...params: any[]) => {
|
||||
const values = params.length === 1 && typeof params[0] === 'object' ? params[0] : params;
|
||||
const rows = this.data.get(table) || [];
|
||||
if (!values || (Array.isArray(values) && values.length === 0)) return rows;
|
||||
return rows.filter((row) =>
|
||||
Object.keys(values).every((key) => row[key] === values[key]),
|
||||
);
|
||||
return rows.filter((row) => Object.keys(values).every((key) => row[key] === values[key]));
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ export function findPassword(service: string): Promise<string | null> {
|
||||
return Promise.resolve('mock-found-password');
|
||||
}
|
||||
|
||||
export function findCredentials(service: string): Promise<Array<{ account: string; password: string }>> {
|
||||
export function findCredentials(
|
||||
service: string,
|
||||
): Promise<Array<{ account: string; password: string }>> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
@@ -25,16 +25,8 @@ vi.mock('../../ipc/config/manager', () => ({
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import {
|
||||
isProcessRunning,
|
||||
closeGeminiNexus,
|
||||
startGeminiNexus,
|
||||
} from '../../ipc/process/handler';
|
||||
import {
|
||||
isNestServerRunning,
|
||||
stopNestServer,
|
||||
bootstrapNestServer,
|
||||
} from '../../server/main';
|
||||
import { isProcessRunning, closeGeminiNexus, startGeminiNexus } from '../../ipc/process/handler';
|
||||
import { isNestServerRunning, stopNestServer, bootstrapNestServer } from '../../server/main';
|
||||
import { ConfigManager } from '../../ipc/config/manager';
|
||||
|
||||
describe('Process Handler', () => {
|
||||
|
||||
@@ -6,11 +6,7 @@ import { Observable } from 'rxjs';
|
||||
// Subclass to access private method
|
||||
class TestableProxyService extends ProxyService {
|
||||
constructor() {
|
||||
super(
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ recordUsage: () => {} } as any,
|
||||
);
|
||||
super({} as any, {} as any, { recordUsage: () => {} } as any);
|
||||
}
|
||||
|
||||
public testProcessStream(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProxyIdeConfigService, SupportedIde } from '@/server/modules/proxy/proxy-ide-config.service';
|
||||
import {
|
||||
ProxyIdeConfigService,
|
||||
SupportedIde,
|
||||
} from '@/server/modules/proxy/proxy-ide-config.service';
|
||||
|
||||
vi.mock('@/ipc/proxy-advanced/service-registry', () => ({
|
||||
registerProxyAdvancedService: vi.fn(),
|
||||
@@ -50,12 +53,8 @@ describe('ProxyIdeConfigService', () => {
|
||||
|
||||
it('should skip internal interfaces', () => {
|
||||
vi.mocked(networkInterfaces).mockReturnValue({
|
||||
lo: [
|
||||
{ family: 'IPv4', internal: true, address: '127.0.0.1' } as any,
|
||||
],
|
||||
eth0: [
|
||||
{ family: 'IPv4', internal: false, address: '10.0.0.5' } as any,
|
||||
],
|
||||
lo: [{ family: 'IPv4', internal: true, address: '127.0.0.1' } as any],
|
||||
eth0: [{ family: 'IPv4', internal: false, address: '10.0.0.5' } as any],
|
||||
});
|
||||
|
||||
expect(service.getLocalIp()).toBe('10.0.0.5');
|
||||
@@ -63,9 +62,7 @@ describe('ProxyIdeConfigService', () => {
|
||||
|
||||
it('should fallback to 127.0.0.1 when no external interface exists', () => {
|
||||
vi.mocked(networkInterfaces).mockReturnValue({
|
||||
lo: [
|
||||
{ family: 'IPv4', internal: true, address: '127.0.0.1' } as any,
|
||||
],
|
||||
lo: [{ family: 'IPv4', internal: true, address: '127.0.0.1' } as any],
|
||||
});
|
||||
|
||||
expect(service.getLocalIp()).toBe('127.0.0.1');
|
||||
|
||||
@@ -10,7 +10,9 @@ vi.mock('@/ipc/proxy-advanced/service-registry', () => ({
|
||||
const mockProxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({ choices: [{ message: { content: 'test' } }] }),
|
||||
handleAnthropicMessages: vi.fn().mockResolvedValue({ content: [{ text: 'test' }] }),
|
||||
handleGeminiGenerateContent: vi.fn().mockResolvedValue({ candidates: [{ content: { parts: [{ text: 'test' }] } }] }),
|
||||
handleGeminiGenerateContent: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ candidates: [{ content: { parts: [{ text: 'test' }] } }] }),
|
||||
} as unknown as ProxyService;
|
||||
|
||||
describe('ProxyReplayService', () => {
|
||||
|
||||
@@ -10,11 +10,7 @@ export function createProviderWrapper() {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
return function AllProviders({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
children,
|
||||
);
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,17 +28,19 @@ export function renderWithProviders(
|
||||
/**
|
||||
* Factory for creating mock cloud accounts.
|
||||
*/
|
||||
export function createMockAccount(overrides?: Partial<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
status: string;
|
||||
quota: number;
|
||||
quotaUsed: number;
|
||||
provider: string;
|
||||
isActive: boolean;
|
||||
}>) {
|
||||
export function createMockAccount(
|
||||
overrides?: Partial<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
status: string;
|
||||
quota: number;
|
||||
quotaUsed: number;
|
||||
provider: string;
|
||||
isActive: boolean;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: 'acc-1',
|
||||
email: '[email protected]',
|
||||
@@ -60,15 +58,17 @@ export function createMockAccount(overrides?: Partial<{
|
||||
/**
|
||||
* Factory for creating mock usage records.
|
||||
*/
|
||||
export function createMockUsage(overrides?: Partial<{
|
||||
id: number;
|
||||
accountId: string;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
timestamp: Date;
|
||||
}>) {
|
||||
export function createMockUsage(
|
||||
overrides?: Partial<{
|
||||
id: number;
|
||||
accountId: string;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
timestamp: Date;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: 1,
|
||||
accountId: 'acc-1',
|
||||
@@ -84,14 +84,16 @@ export function createMockUsage(overrides?: Partial<{
|
||||
/**
|
||||
* Factory for creating mock proxy metrics.
|
||||
*/
|
||||
export function createMockProxyMetrics(overrides?: Partial<{
|
||||
id: number;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
timestamp: Date;
|
||||
}>) {
|
||||
export function createMockProxyMetrics(
|
||||
overrides?: Partial<{
|
||||
id: number;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
timestamp: Date;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: 1,
|
||||
endpoint: '/v1/chat/completions',
|
||||
@@ -106,13 +108,15 @@ export function createMockProxyMetrics(overrides?: Partial<{
|
||||
/**
|
||||
* Factory for creating mock app config.
|
||||
*/
|
||||
export function createMockConfig(overrides?: Partial<{
|
||||
proxyPort: number;
|
||||
proxyTimeout: number;
|
||||
theme: string;
|
||||
language: string;
|
||||
autoSwitch: boolean;
|
||||
}>) {
|
||||
export function createMockConfig(
|
||||
overrides?: Partial<{
|
||||
proxyPort: number;
|
||||
proxyTimeout: number;
|
||||
theme: string;
|
||||
language: string;
|
||||
autoSwitch: boolean;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
proxyPort: 3000,
|
||||
proxyTimeout: 30000,
|
||||
|
||||
+80
-20
@@ -87,50 +87,110 @@ export type TokenUsageRow = z.infer<typeof TokenUsageRowSchema>;
|
||||
|
||||
export const HourlyUsageSchema = z.object({
|
||||
bucket: z.string(),
|
||||
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||
promptTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
completionTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
totalTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
requests: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
});
|
||||
|
||||
export type HourlyUsage = z.infer<typeof HourlyUsageSchema>;
|
||||
|
||||
export const DailyUsageSchema = z.object({
|
||||
bucket: z.string(),
|
||||
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||
promptTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
completionTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
totalTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
requests: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
});
|
||||
|
||||
export type DailyUsage = z.infer<typeof DailyUsageSchema>;
|
||||
|
||||
export const WeeklyUsageSchema = z.object({
|
||||
bucket: z.string(),
|
||||
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||
promptTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
completionTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
totalTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
requests: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
});
|
||||
|
||||
export type WeeklyUsage = z.infer<typeof WeeklyUsageSchema>;
|
||||
|
||||
export const MonthlyUsageSchema = z.object({
|
||||
bucket: z.string(),
|
||||
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||
promptTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
completionTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
totalTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
requests: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
});
|
||||
|
||||
export type MonthlyUsage = z.infer<typeof MonthlyUsageSchema>;
|
||||
|
||||
export const ModelUsageSchema = z.object({
|
||||
model: z.string(),
|
||||
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||
promptTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
completionTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
totalTokens: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
requests: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
});
|
||||
|
||||
export type ModelUsage = z.infer<typeof ModelUsageSchema>;
|
||||
@@ -47,7 +47,7 @@ export default defineConfig(({ mode }) => {
|
||||
'winston-daily-rotate-file',
|
||||
'moment',
|
||||
'@nestjs/microservices',
|
||||
'@nestjs/websockets'
|
||||
'@nestjs/websockets',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+12
-3
@@ -6,14 +6,23 @@ export default defineConfig({
|
||||
plugins: [viteTsconfigPaths()],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: 'keytar', replacement: path.resolve(process.cwd(), './src/tests/unit/mocks/keytar.ts') },
|
||||
{ find: 'better-sqlite3', replacement: path.resolve(process.cwd(), './src/tests/unit/mocks/better-sqlite3.ts') },
|
||||
{
|
||||
find: 'keytar',
|
||||
replacement: path.resolve(process.cwd(), './src/tests/unit/mocks/keytar.ts'),
|
||||
},
|
||||
{
|
||||
find: 'better-sqlite3',
|
||||
replacement: path.resolve(process.cwd(), './src/tests/unit/mocks/better-sqlite3.ts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
include: ['src/tests/unit/**/*.{test,spec}.{ts,tsx}', 'src/tests/integration/**/*.{test,spec}.{ts,tsx}'],
|
||||
include: [
|
||||
'src/tests/unit/**/*.{test,spec}.{ts,tsx}',
|
||||
'src/tests/integration/**/*.{test,spec}.{ts,tsx}',
|
||||
],
|
||||
exclude: ['src/tests/e2e/**/*'],
|
||||
setupFiles: ['src/tests/unit/setup.ts'],
|
||||
coverage: {
|
||||
|
||||
Reference in new issue
Block a user