Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a648f2a281 | ||
|
|
b1c76f3b69 | ||
|
|
1ad7ae6515 | ||
|
|
fbf21c4fa9 | ||
|
|
d35db9756d | ||
|
|
3088c9c542 | ||
|
|
24142115c4 | ||
|
|
bac7b5591e | ||
|
|
b80531f8a8 | ||
|
|
3573009e0a | ||
|
|
095ea6131d | ||
|
|
69e455a565 | ||
|
|
04014b8c7d | ||
|
|
afe8859f16 | ||
|
|
bf4bcf4f96 | ||
|
|
fd1b2b8bc0 | ||
|
|
0be7d7fea2 | ||
|
|
c73537d44c | ||
|
|
2f42d70d78 | ||
|
|
425b0009b8 | ||
|
|
9b37bdb2d9 | ||
|
|
1258b32cb5 | ||
|
|
98c08bd64f | ||
|
|
8c8b328120 | ||
|
|
a225791e71 | ||
|
|
1a1c0acd16 | ||
|
|
ce21283170 | ||
|
|
2a5e54184f |
No files matched your search
@@ -1,17 +1,29 @@
|
||||
name: Check formatting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests-unit:
|
||||
check:
|
||||
name: Prettier Format Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run format check
|
||||
run: npm run format
|
||||
@@ -1,17 +1,29 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: ESLint Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
@@ -1,22 +1,53 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests-unit:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
run: npm test
|
||||
|
||||
type-check:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npm run type-check
|
||||
|
||||
# NOTE: E2E tests are disabled in CI because Electron apps require a graphical environment.
|
||||
# Run E2E tests locally with: npm run test:e2e
|
||||
@@ -0,0 +1,29 @@
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
|
||||
Developers and power users who manage multiple AI accounts (Google Gemini, Claude) through a desktop Electron app. They use IDEs like Cursor, OpenCode, and Windsurf. They need to monitor token usage across accounts in real-time to avoid quota exhaustion.
|
||||
|
||||
### Brand Personality
|
||||
|
||||
- **Professional**: Clean, precise, no clutter
|
||||
- **Technical**: Data-dense but readable, developer-friendly
|
||||
- **Efficient**: Information at a glance, minimal clicks
|
||||
- 3 words: "precise", "technical", "focused"
|
||||
|
||||
### Aesthetic Direction
|
||||
|
||||
- Dark mode primary (this is a developer tool used for long sessions)
|
||||
- Deep navy/slate backgrounds, not pure black
|
||||
- Sharp accent colors for data visualization (subtle blues, teals, amber for warnings)
|
||||
- High information density without visual noise
|
||||
- Monospace for numbers/data, clean sans-serif for UI chrome
|
||||
- No gradients on text, no glassmorphism, no rounded-everything
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. Data first — charts and numbers are the heroes
|
||||
2. Every pixel earns its place — no decorative elements
|
||||
3. Density over whitespace — developers want to see everything
|
||||
4. Motion only for state changes — no gratuitous animations
|
||||
5. Consistent with shadcn/ui + Tailwind system already in place
|
||||
@@ -0,0 +1,58 @@
|
||||
Here is the code review report for the Token Usage Counter feature.
|
||||
|
||||
## Summary
|
||||
|
||||
The review found 2 critical security issues and 2 warnings. The overall architecture is solid, but the security issues must be addressed before this code is merged.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: SQL Injection Vulnerability in TokenUsageRepo filter methods
|
||||
|
||||
**Issue:** `buildAccountFilter` concatenates `accountId` directly into raw SQL.
|
||||
**Fix:** Use parameterized queries.
|
||||
|
||||
```typescript
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: string[] } {
|
||||
return accountId ? { sql: `AND account_id = ?`, params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
```
|
||||
|
||||
### CR-02: Missing validation in TokenUsageRepo queries
|
||||
|
||||
**Issue:** `buildDateFilter` directly interpolates `start` and `end` arguments.
|
||||
**Fix:** Ensure parameters are passed safely using parameterized queries.
|
||||
|
||||
```typescript
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Synchronous Database Writing in ProxyService
|
||||
|
||||
**Issue:** `recordUsage` calls a synchronous database insertion on every proxy request, which can cause severe performance bottlenecks under load.
|
||||
**Fix:** Consider making the usage recording asynchronous (fire-and-forget), using an asynchronous database driver, or implementing a batching mechanism.
|
||||
|
||||
### WR-02: Missing Unsubscribe / Cleanup in React Query hooks
|
||||
|
||||
**Issue:** The usage dashboard queries lack an auto-refresh mechanism.
|
||||
**Fix:** Add `refetchInterval: 10000` to the query configuration.
|
||||
|
||||
## Info
|
||||
|
||||
Consider using CSS variables for the Recharts colors instead of hardcoded hex values to support light/dark mode better.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
phase: 02-code-review-command
|
||||
reviewed: 2026-04-22T14:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 10
|
||||
files_reviewed_list:
|
||||
- src/ipc/database/cloudHandler.ts
|
||||
- src/ipc/database/schema.ts
|
||||
- src/ipc/database/usageHandler.ts
|
||||
- src/ipc/router.ts
|
||||
- src/ipc/usage/router.ts
|
||||
- src/server/modules/proxy/proxy.module.ts
|
||||
- src/server/modules/proxy/proxy.service.ts
|
||||
- src/server/modules/usage/token-usage.service.ts
|
||||
- src/types/db.ts
|
||||
- src/routes/usage.tsx
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 02-code-review-command: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-04-22T14:00:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 10
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
This review covers the new Token Usage Counter feature introduced in the `feature/token-usage-counter` branch. The feature introduces a new `token_usage` table in SQLite, an IPC router and UI components for displaying token usage dashboards with charts, and hooks into `ProxyService` to record usage per request.
|
||||
|
||||
Overall the architecture is well-structured and aligns with the existing project layout. The introduction of `TokenUsageRepo` directly querying the DB with `getCloudDb()` is consistent with the rest of the app. The UI is built using Recharts and properly localized.
|
||||
|
||||
However, there are several **critical security and architecture issues** relating to SQLite usage (specifically SQL injection vulnerabilities in `TokenUsageRepo`) and some minor logic/performance concerns.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: SQL Injection Vulnerability in TokenUsageRepo filter methods
|
||||
|
||||
**File:** `src/ipc/database/usageHandler.ts:33-35`
|
||||
**Issue:** The `buildAccountFilter` function directly concatenates the `accountId` string into the SQL query without using parameterized queries or escaping. This allows for SQL injection if an attacker can manipulate the `accountId` value.
|
||||
**Fix:**
|
||||
Use parameterized queries (e.g., `?` placeholders) and pass the `accountId` as an argument to `raw.prepare().all(accountId)`.
|
||||
|
||||
```typescript
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: string[] } {
|
||||
return accountId ? { sql: `AND account_id = ?`, params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
```
|
||||
|
||||
### CR-02: Missing try-catch and cleanup around database connection in TokenUsageRepo queries
|
||||
|
||||
**File:** `src/ipc/database/usageHandler.ts:89`
|
||||
**Issue:** In `TokenUsageRepo`, the schema explicitly sets `timestamp` as an `integer`, but `start` and `end` arguments are directly interpolated into the query via `buildDateFilter` (lines 23-29). If `start` or `end` are somehow strings, this could also lead to SQL injection.
|
||||
**Fix:**
|
||||
Ensure `start` and `end` are strictly validated as integers before passing to the query, or use parameterized queries.
|
||||
|
||||
```typescript
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Synchronous Database Writing in ProxyService
|
||||
|
||||
**File:** `src/server/modules/proxy/proxy.service.ts:57`
|
||||
**Issue:** The `recordUsage` method calls `this.tokenUsageService.recordUsage()`, which internally calls `TokenUsageRepo.recordUsage()`. `TokenUsageRepo.recordUsage()` performs a synchronous database insertion (`orm.insert(...).run()`) and closes the connection. Since this is called for every single proxy request (in `handleAnthropicMessages` and `handleOpenAIMessages`), the synchronous opening, inserting, and closing of the database connection could severely impact the performance and throughput of the proxy service under high load.
|
||||
**Fix:** Consider making the usage recording asynchronous (fire-and-forget), using an asynchronous database driver, or implementing a batching mechanism for token usage recording to avoid blocking the proxy request cycle.
|
||||
|
||||
### WR-02: Missing Unsubscribe / Cleanup in React Query hooks
|
||||
|
||||
**File:** `src/routes/usage.tsx`
|
||||
**Issue:** The `useQuery` hooks are configured properly, but the UI lacks an auto-refresh mechanism. Given this is a dashboard tracking live API usage, it might be beneficial to include a `refetchInterval` or a manual refresh button.
|
||||
**Fix:** Add `refetchInterval: 10000` to the query configuration.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: UI Chart Colors and Styling
|
||||
|
||||
**File:** `src/routes/usage.tsx:23`
|
||||
**Issue:** The `COLORS` array is hardcoded with hex values. In a themable application (e.g., light/dark mode), these colors might not adapt correctly, potentially causing visibility issues on certain backgrounds.
|
||||
**Fix:** Consider using CSS variables from the theme (e.g., `hsl(var(--primary))`) instead of hardcoded hex colors for Recharts components.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-04-22T14:00:00Z_
|
||||
_Reviewer: OpenCode (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,92 @@
|
||||
# Proxy Page — Feature Brainstorm
|
||||
|
||||
> Generated from designer agent analysis of `src/routes/proxy.tsx`
|
||||
> 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)
|
||||
- Windsurf (`config.yaml` snippet)
|
||||
- Claude Code / CLI tools
|
||||
- Include the local IP, port, and API key pre-filled
|
||||
- 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)
|
||||
- `/v1/messages` (Anthropic-compatible)
|
||||
- `/v1beta/models/...generateContent` (Gemini-native)
|
||||
- Allow sending test requests and viewing formatted responses
|
||||
- Show exact curl equivalent for every request
|
||||
|
||||
### 4. Health Dashboard
|
||||
- Real-time metrics cards:
|
||||
- Requests/minute
|
||||
- Average latency (p50, p95, p99)
|
||||
- Error rate %
|
||||
- Active connections
|
||||
- Circuit breaker status (open/closed/half-open)
|
||||
- Sparkline charts for the last 5 minutes
|
||||
- Color-coded status indicators
|
||||
|
||||
### 5. Model Availability Status
|
||||
- Live grid showing which models are currently available
|
||||
- Per-model indicators:
|
||||
- Green: available
|
||||
- Yellow: rate-limited (retry after)
|
||||
- Red: unavailable / quota exhausted
|
||||
- Show last-used time and remaining quota (if known)
|
||||
- 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
|
||||
- Filter by error type (rate limit, timeout, auth, network)
|
||||
- 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
|
||||
- Show queue depth / waiting requests
|
||||
- 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
|
||||
- Verify model mapping resolution
|
||||
- Check upstream API reachability
|
||||
- Validate API key format
|
||||
- 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
|
||||
- Unique accounts used
|
||||
- Top 3 models used
|
||||
- Uptime counter
|
||||
- Resettable per-session (not persisted)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- Model availability data is already fetched for the account pool page
|
||||
@@ -0,0 +1,81 @@
|
||||
# Proxy Page — UI/UX Improvements
|
||||
|
||||
> Generated from designer agent analysis of `src/routes/proxy.tsx`
|
||||
> 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
|
||||
4. **Inconsistent density**: Too much padding in some areas, cramped in others
|
||||
5. **Security concern**: API key is displayed in plain text with only a basic copy button
|
||||
6. **No status context**: User can't see which account/model is being used for requests
|
||||
|
||||
## 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
|
||||
- Show "Last used" timestamp
|
||||
- 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
|
||||
- Show which mappings are active/inactive
|
||||
- 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"
|
||||
- Action: "Start Proxy" button prominently displayed
|
||||
- When no accounts are configured, show a CTA to add accounts
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
- **Security first**: API keys and sensitive data should never be displayed casually
|
||||
@@ -0,0 +1,125 @@
|
||||
# Proxy Page — Advanced Features Hidden in Backend
|
||||
|
||||
> Generated from oracle agent analysis of `src/server/modules/proxy/`
|
||||
> 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
|
||||
- `scheduler.backoffStrategy`: `exponential` | `linear` | `fixed`
|
||||
- `scheduler.priorityAccounts`: Array of account IDs to prioritize
|
||||
|
||||
**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
|
||||
- Export to JSON/CSV
|
||||
|
||||
**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
|
||||
- Metrics: failure rate, last failure time, consecutive successes
|
||||
|
||||
**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
|
||||
- Configurable test suites
|
||||
|
||||
**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
|
||||
- Fallback chain: if mapped model unavailable, try next
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
- OpenCode (`.env`)
|
||||
- Claude Code (`claude_desktop_config.json`)
|
||||
- Generic OpenAI client
|
||||
- Includes local IP detection, port, and API key
|
||||
|
||||
**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
|
||||
- Active connection gauge
|
||||
- Token throughput (tokens/minute)
|
||||
- Account utilization percentage
|
||||
|
||||
**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
|
||||
- Compare original vs. replayed response
|
||||
- Export request as curl command
|
||||
|
||||
**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
|
||||
- `idleTimeout`: Close idle connections after N seconds
|
||||
|
||||
**UI exposure**: None. Hardcoded defaults.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
This would transform the proxy page from a simple on/off switch into a comprehensive API gateway dashboard.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Proxy Page — Code Organization Refactoring Plan
|
||||
|
||||
> Generated from oracle agent analysis of `src/routes/proxy.tsx`
|
||||
> 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)
|
||||
- Derived state (code examples, URLs)
|
||||
- UI rendering (service card, model mapping, code examples, local access banner)
|
||||
- Event handlers (start/stop, copy, protocol change)
|
||||
- Constants and helper functions
|
||||
|
||||
## Target Architecture
|
||||
Split into **7+ files** following the project's conventions:
|
||||
|
||||
```
|
||||
src/
|
||||
├── routes/
|
||||
│ └── proxy.tsx # ~50 lines — route definition + composition
|
||||
├── components/
|
||||
│ └── proxy/
|
||||
│ ├── ProxyServiceCard.tsx
|
||||
│ ├── ProxyModelMappingCard.tsx
|
||||
│ ├── ProxyUsageExamplesCard.tsx
|
||||
│ └── ProxyLocalAccessBanner.tsx
|
||||
├── hooks/
|
||||
│ ├── useProxyConfig.ts
|
||||
│ ├── useLocalIps.ts
|
||||
│ └── useProxyExamples.ts
|
||||
├── constants/
|
||||
│ └── proxy.ts # API endpoints, default ports, protocols
|
||||
└── utils/
|
||||
└── proxy.ts # URL builders, formatters, parsers
|
||||
```
|
||||
|
||||
## File Breakdown
|
||||
|
||||
### 1. `src/hooks/useProxyConfig.ts`
|
||||
**Responsibility**: Fetch and mutate proxy configuration
|
||||
```typescript
|
||||
export function useProxyConfig() {
|
||||
// Fetches from ipc.client.proxy.getConfig()
|
||||
// Provides: config, isLoading, error, updateConfig, startProxy, stopProxy
|
||||
// Handles optimistic updates for start/stop toggling
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `src/hooks/useLocalIps.ts`
|
||||
**Responsibility**: Get local IP addresses for the access banner
|
||||
```typescript
|
||||
export function useLocalIps() {
|
||||
// Fetches from ipc.client.system.getLocalIps()
|
||||
// Returns: ips[], isLoading, error
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
// Updates when port, protocol, or API key changes
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `src/components/proxy/ProxyServiceCard.tsx`
|
||||
**Responsibility**: The main service control card
|
||||
- Start/stop toggle
|
||||
- Port input
|
||||
- Protocol selector
|
||||
- API key display (with mask/reveal)
|
||||
- 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;
|
||||
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;
|
||||
export function formatCurlExample(url: string, model: string, apiKey: string): string;
|
||||
```
|
||||
|
||||
## Route File After Refactoring
|
||||
|
||||
```typescript
|
||||
// src/routes/proxy.tsx — ~50 lines
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useProxyConfig } from '@/hooks/useProxyConfig';
|
||||
import { useLocalIps } from '@/hooks/useLocalIps';
|
||||
import { ProxyServiceCard } from '@/components/proxy/ProxyServiceCard';
|
||||
import { ProxyModelMappingCard } from '@/components/proxy/ProxyModelMappingCard';
|
||||
import { ProxyUsageExamplesCard } from '@/components/proxy/ProxyUsageExamplesCard';
|
||||
import { ProxyLocalAccessBanner } from '@/components/proxy/ProxyLocalAccessBanner';
|
||||
|
||||
function ProxyPage() {
|
||||
const { config, isLoading, startProxy, stopProxy } = useProxyConfig();
|
||||
const { ips } = useLocalIps();
|
||||
|
||||
if (isLoading) return <ProxySkeleton />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<ProxyServiceCard config={config} onStart={startProxy} onStop={stopProxy} />
|
||||
<ProxyModelMappingCard mappings={config.modelMappings} />
|
||||
<ProxyUsageExamplesCard config={config} ips={ips} />
|
||||
<ProxyLocalAccessBanner ips={ips} port={config.port} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`
|
||||
4. Extract and test each hook independently
|
||||
5. Add Storybook stories for each component (if Storybook is used)
|
||||
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
|
||||
- **Code review**: Smaller files = faster, more focused PRs
|
||||
- **Onboarding**: New developers can understand the page in minutes
|
||||
@@ -0,0 +1,132 @@
|
||||
# Proxy Page — Code Quality Issues
|
||||
|
||||
> Generated from fixer agent analysis of `src/routes/proxy.tsx`
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Severity Legend
|
||||
- 🔴 **Critical**: Bugs or crashes
|
||||
- 🟡 **High**: Performance or reliability issues
|
||||
- 🟢 **Medium**: Maintainability or best practice violations
|
||||
|
||||
## 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 {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success('Copied!');
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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);
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
### 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;
|
||||
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;
|
||||
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"
|
||||
min={1024}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (value >= 1024 && value <= 65535) setPort(value);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
- Add unit tests for `useProxyConfig` hook (mock IPC client)
|
||||
- Test race conditions: rapid start/stop clicks
|
||||
- Test clipboard failure path
|
||||
- Test port validation edge cases (0, 80, 65536, NaN)
|
||||
- Test with `react-testing-library` + `user-event`
|
||||
@@ -0,0 +1,141 @@
|
||||
# Proxy Page — Architecture Overview
|
||||
|
||||
> Generated from explorer agent mapping of proxy-related files
|
||||
> Date: 2026-04-22
|
||||
|
||||
## 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 |
|
||||
|
||||
### 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 |
|
||||
|
||||
### IPC Layer
|
||||
| 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 |
|
||||
|
||||
### Types & Schema
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
|
||||
| `src/types/proxy.ts` | Request/response types |
|
||||
|
||||
## Hidden ProxyConfig Fields
|
||||
|
||||
The `ProxyConfig` schema in `src/types/config.ts` contains many fields never shown in the UI:
|
||||
|
||||
```typescript
|
||||
interface ProxyConfig {
|
||||
// Shown in UI
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
protocol: 'http' | 'https';
|
||||
apiKey: string;
|
||||
|
||||
// Hidden from UI
|
||||
scheduler: {
|
||||
maxRequestsPerAccount: number;
|
||||
maxWaitTime: number;
|
||||
retryAttempts: number;
|
||||
backoffStrategy: 'exponential' | 'linear' | 'fixed';
|
||||
priorityAccounts: string[];
|
||||
};
|
||||
upstreamProxy?: {
|
||||
url: string;
|
||||
auth?: { username: string; password: string };
|
||||
bypassList: string[];
|
||||
};
|
||||
circuitBreaker: {
|
||||
failureThreshold: number;
|
||||
recoveryTimeout: number;
|
||||
halfOpenMaxCalls: number;
|
||||
};
|
||||
logging: {
|
||||
enabled: boolean;
|
||||
maxEntries: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
metrics: {
|
||||
enabled: boolean;
|
||||
histogramBuckets: number[];
|
||||
};
|
||||
modelMappings: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────┐ IPC ┌──────────────────┐
|
||||
│ proxy.tsx │ ◄──────────► │ proxy/router.ts │
|
||||
│ (React UI) │ │ (IPC handlers) │
|
||||
└─────────────────┘ └────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ ProxyController │
|
||||
│ (HTTP endpoints) │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ ProxyService │
|
||||
│ (Core orchestrator) │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
|
||||
│ Scheduler │ │ CircuitBreaker│ │Model Mapper │
|
||||
└─────────────┘ └───────────────┘ └──────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
|
||||
│TrafficLogger│ │MetricsService │ │ParityService │
|
||||
└─────────────┘ └───────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## 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
|
||||
4. **No real-time communication** — UI polls for status; no WebSocket or SSE for live updates
|
||||
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()`
|
||||
- `proxy.getCircuitBreakerStatus()`
|
||||
- `proxy.getMetrics()`
|
||||
- `proxy.replayRequest()`
|
||||
3. Create a WebSocket or SSE channel for live traffic updates
|
||||
4. Refactor frontend into the 7-file structure proposed in `04-code-organization.md`
|
||||
@@ -57,6 +57,7 @@ Crie ou edite o arquivo de configuração (recomendado: `~/.config/opencode/open
|
||||
Depois de salvar, reinicie o OpenCode ou use `/models` para selecionar.
|
||||
|
||||
**Comando rápido para adicionar chave:**
|
||||
|
||||
```bash
|
||||
opencode /connect
|
||||
# Escolha "Other" → digite o provider_id (ex: minha-ia-custom) → cole a API key
|
||||
@@ -73,6 +74,7 @@ POST {baseURL}/chat/completions
|
||||
```
|
||||
|
||||
**Headers enviados:**
|
||||
|
||||
- `Authorization: Bearer <sua-api-key>`
|
||||
- `Content-Type: application/json`
|
||||
- Headers customizados que você definir em `options.headers`
|
||||
@@ -95,12 +97,15 @@ POST {baseURL}/chat/completions
|
||||
"stream": true,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 8192,
|
||||
"tools": [ /* array de tools se o agente estiver usando ferramentas */ ],
|
||||
"tools": [
|
||||
/* array de tools se o agente estiver usando ferramentas */
|
||||
],
|
||||
"tool_choice": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
**Parâmetros comuns que o OpenCode envia:**
|
||||
|
||||
- `messages` (array completo do histórico)
|
||||
- `stream: true` (quase sempre, para UX em tempo real)
|
||||
- `temperature`, `max_tokens`, `top_p`
|
||||
@@ -165,19 +170,23 @@ Se sua API suportar `tools`, o OpenCode pode:
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\": \"src/app.ts\"}"
|
||||
}
|
||||
}]
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\": \"src/app.ts\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -198,15 +207,15 @@ O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderi
|
||||
|
||||
### 5. Requisitos Mínimos da Sua API para Funcionar Perfeitamente
|
||||
|
||||
| Recurso | Obrigatório? | Recomendado? | Observação |
|
||||
|----------------------|--------------|--------------|----------|
|
||||
| `/v1/chat/completions` | Sim | - | Endpoint principal |
|
||||
| Streaming (SSE) | Sim | - | Essencial para UX |
|
||||
| Tool Calling | Não | Sim | Para agentes completos |
|
||||
| System messages | Sim | - | - |
|
||||
| Multi-turn history | Sim | - | - |
|
||||
| Usage stats | Não | Sim | Mostra tokens gastos |
|
||||
| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI |
|
||||
| Recurso | Obrigatório? | Recomendado? | Observação |
|
||||
| ---------------------- | ------------ | ------------ | ---------------------------------- |
|
||||
| `/v1/chat/completions` | Sim | - | Endpoint principal |
|
||||
| Streaming (SSE) | Sim | - | Essencial para UX |
|
||||
| Tool Calling | Não | Sim | Para agentes completos |
|
||||
| System messages | Sim | - | - |
|
||||
| Multi-turn history | Sim | - | - |
|
||||
| Usage stats | Não | Sim | Mostra tokens gastos |
|
||||
| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI |
|
||||
|
||||
**Endpoint exato esperado:**
|
||||
`POST https://sua-api.com/v1/chat/completions`
|
||||
@@ -256,6 +265,7 @@ O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderi
|
||||
|
||||
Sua API **precisa ser 100% compatível** com o endpoint OpenAI `/v1/chat/completions` (streaming + tool calling recomendado).
|
||||
O OpenCode vai:
|
||||
|
||||
1. Configurar via `opencode.json` (baseURL + apiKey)
|
||||
2. Enviar requisições padrão OpenAI
|
||||
3. Receber streaming ou resposta completa
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Brand Briefing — Gemini Nexus
|
||||
|
||||
## 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 |
|
||||
| **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) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Propósito do Aplicativo
|
||||
|
||||
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
|
||||
- **Proxy API local**: Servidor proxy integrado compatível OpenAI/Anthropic (porta 8045)
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## 3. Tom de Voz / Personalidade
|
||||
|
||||
- **Profissional e técnica**: Termos como "Nexus", "Proxy", "Gateway", "Quota", "Circuit Breaker"
|
||||
- **Confiável e estável**: Monitoramento, health checks, logging robusto
|
||||
- **Developer-first**: Suporte a múltiplas IDEs, geração de cURL/Python
|
||||
- **Moderna e minimalista**: Interface limpa, componentes modulares
|
||||
- **Global**: Suporte a i18n (EN, PT-BR, ZH, RU, VI)
|
||||
|
||||
---
|
||||
|
||||
## 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 | — |
|
||||
|
||||
### 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
|
||||
- Interface limpa com espaçamento generoso
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Conclusão
|
||||
|
||||
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**
|
||||
- **Modernidade** e **minimalismo**
|
||||
@@ -31,7 +31,7 @@ export default defineConfig([
|
||||
'unused-imports': unusedImports,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync } from 'child_process';
|
||||
import crypto from 'crypto';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import type {
|
||||
@@ -17,16 +18,26 @@ import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
import MakerAppImage from '@pengx17/electron-forge-maker-appimage';
|
||||
// @ts-ignore
|
||||
import setLanguages from 'electron-packager-languages';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
|
||||
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
|
||||
const ResolvedMakerAppImage = MakerAppImage;
|
||||
const ResolvedMakerAppImage: any = (MakerAppImage as any).default || MakerAppImage;
|
||||
const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']);
|
||||
const windowsExecutableName = 'gemini-nexus';
|
||||
|
||||
function isWixAvailable(): boolean {
|
||||
try {
|
||||
execSync('candle -?', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
|
||||
|
||||
const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip|deb|rpm|msi)$/;
|
||||
@@ -176,25 +187,8 @@ function getChecksumArchLabel(platform: string, arch: string) {
|
||||
}
|
||||
|
||||
const appImageMaker = new ResolvedMakerAppImage({
|
||||
config: {
|
||||
icons: [
|
||||
{
|
||||
file: 'images/32x32.png',
|
||||
size: 32,
|
||||
},
|
||||
{
|
||||
file: 'images/64x64.png',
|
||||
size: 64,
|
||||
},
|
||||
{
|
||||
file: 'images/128x128.png',
|
||||
size: 128,
|
||||
},
|
||||
{
|
||||
file: 'images/[email protected]',
|
||||
size: 256,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
icon: 'images/128x128.png',
|
||||
},
|
||||
});
|
||||
appImageMaker.name = '@pengx17/electron-forge-maker-appimage';
|
||||
@@ -449,10 +443,9 @@ const config: ForgeConfig = {
|
||||
makers: [
|
||||
new MakerSquirrel({
|
||||
setupIcon: 'images/icon.ico',
|
||||
iconUrl:
|
||||
'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
|
||||
iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
|
||||
}),
|
||||
...(process.platform === 'win32' && process.arch === 'x64'
|
||||
...(process.platform === 'win32' && process.arch === 'x64' && isWixAvailable()
|
||||
? [
|
||||
new MakerWix({
|
||||
language: 1033,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gemini-nexus",
|
||||
"productName": "Gemini Nexus",
|
||||
"version": "0.11.1",
|
||||
"version": "0.13.0",
|
||||
"description": "Gemini Nexus - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
@@ -12,6 +12,8 @@
|
||||
"make": "electron-forge make",
|
||||
"make:win": "node scripts/build-windows.js",
|
||||
"publish": "electron-forge publish",
|
||||
"setup": "node scripts/setup-dev.js",
|
||||
"postinstall": "node scripts/setup-dev.js --check-only",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check .",
|
||||
"format:write": "prettier --write .",
|
||||
@@ -25,12 +27,12 @@
|
||||
"author": "evandrodevbr",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.11.1",
|
||||
"@electron-forge/cli": "^7.6.1",
|
||||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.11.1",
|
||||
"@electron-forge/maker-rpm": "^7.11.1",
|
||||
"@electron-forge/maker-squirrel": "^7.11.1",
|
||||
"@electron-forge/maker-wix": "^7.11.1",
|
||||
"@electron-forge/maker-wix": "^7.9.0",
|
||||
"@electron-forge/maker-zip": "^7.11.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
|
||||
"@electron-forge/plugin-fuses": "^7.11.1",
|
||||
@@ -41,14 +43,14 @@
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.0",
|
||||
"@pengx17/electron-forge-maker-appimage": "^1.2.1",
|
||||
"@pengx17/electron-forge-maker-appimage": "^1.0.2",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^12.0.6",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/router-plugin": "^1.135.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -65,7 +67,7 @@
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^37.3.1",
|
||||
"electron": "^41.3.0",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-packager-languages": "^0.6.0",
|
||||
"electron-playwright-helpers": "^1.8.2",
|
||||
@@ -79,6 +81,7 @@
|
||||
"happy-dom": "^20.8.9",
|
||||
"icon-gen": "^5.0.0",
|
||||
"jsdom": "^27.1.0",
|
||||
"node-abi": "^4.28.0",
|
||||
"node-addon-api": "^8.3.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
@@ -90,18 +93,22 @@
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vite": "^5.4.21",
|
||||
"vite": "^8.0.10",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/microservices": "^11.1.18",
|
||||
"@nestjs/platform-express": "^11.1.18",
|
||||
"@nestjs/platform-fastify": "^11.1.18",
|
||||
"@nestjs/platform-fastify": "^11.0.20",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
"@nivo/bar": "^0.99.0",
|
||||
"@nivo/core": "^0.99.0",
|
||||
"@nivo/line": "^0.99.0",
|
||||
"@nivo/pie": "^0.99.0",
|
||||
"@orpc/client": "^1.13.14",
|
||||
"@orpc/server": "^1.13.14",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -124,7 +131,6 @@
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-router": "^1.139.3",
|
||||
"@tanstack/react-router-devtools": "^1.135.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"axios": "^1.15.0",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -134,8 +140,9 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify": "^5.8.5",
|
||||
"find-process": "^2.0.0",
|
||||
"gpt-tokenizer": "^3.4.0",
|
||||
"i18next": "^25.6.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"keytar": "^7.9.0",
|
||||
@@ -151,7 +158,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"undici": "^7.16.0",
|
||||
"update-electron-app": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
@@ -163,5 +170,18 @@
|
||||
"@sentry/cli",
|
||||
"@swc/core",
|
||||
"protobufjs"
|
||||
]
|
||||
],
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"node-abi": "^4.28.0",
|
||||
"fastify": "^5.8.5",
|
||||
"tar": "^7.5.13",
|
||||
"@octokit/request-error": "^5.1.1",
|
||||
"@octokit/plugin-paginate-rest": "^9.2.2",
|
||||
"@octokit/request": "^8.4.1",
|
||||
"tmp": "^0.2.4",
|
||||
"@tootallnate/once": "^3.0.1",
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const fs = require('fs');
|
||||
let file = fs.readFileSync('src/routes/proxy.tsx', 'utf8');
|
||||
|
||||
// The Proxy layout also uses heavy cards
|
||||
file = file.replace(/<Card>/g, '<Card className="bg-background border border-white/5 shadow-none">');
|
||||
|
||||
fs.writeFileSync('src/routes/proxy.tsx', file);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const fs = require('fs');
|
||||
let file = fs.readFileSync('src/routes/settings.tsx', 'utf8');
|
||||
|
||||
// The settings page uses standard cards that look bulky in minimal dark mode
|
||||
file = file.replace(/<div className="flex flex-col gap-6">/g, '<div className="flex flex-col gap-2">');
|
||||
file = file.replace(/<Card>/g, '<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">');
|
||||
file = file.replace(/<CardHeader>/g, '<CardHeader className="px-0 pt-4">');
|
||||
file = file.replace(/<CardContent>/g, '<CardContent className="px-0">');
|
||||
|
||||
fs.writeFileSync('src/routes/settings.tsx', file);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
allowBuilds:
|
||||
'@bitdisaster/exe-icon-extractor': true
|
||||
'@nestjs/core': true
|
||||
'@sentry/cli': true
|
||||
'@swc/core': true
|
||||
better-sqlite3: true
|
||||
electron: true
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
keytar: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { execSync, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* Cross-platform development environment setup for Gemini Nexus.
|
||||
*
|
||||
* Detects and installs native build toolchains required by better-sqlite3,
|
||||
* keytar, and other native Node.js modules used in this Electron project.
|
||||
*
|
||||
* Platforms:
|
||||
* - Windows → Visual Studio 2022 Build Tools (C++ workload)
|
||||
* - macOS → Xcode Command Line Tools
|
||||
* - Ubuntu → build-essential, python3, libsecret-1-dev
|
||||
* - Manjaro/Arch → base-devel, python, libsecret
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/setup-dev.js # auto-detect and install/guide
|
||||
* node scripts/setup-dev.js --check-only # only verify, don't install
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { execSync, spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// ─── Terminal Color Helpers ─────────────────────────────────────────────────
|
||||
|
||||
const RESET = '\x1b[0m';
|
||||
const BOLD = '\x1b[1m';
|
||||
const DIM = '\x1b[2m';
|
||||
const RED = '\x1b[31m';
|
||||
const GREEN = '\x1b[32m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const CYAN = '\x1b[36m';
|
||||
const MAGENTA = '\x1b[35m';
|
||||
|
||||
// Disable colors if NO_COLOR is set or output is piped
|
||||
const useColor = process.env.NO_COLOR === undefined && process.stdout.isTTY;
|
||||
|
||||
function color(code, text) {
|
||||
return useColor ? `${code}${text}${RESET}` : text;
|
||||
}
|
||||
|
||||
function header(text) {
|
||||
console.log(`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`);
|
||||
}
|
||||
|
||||
function success(text) {
|
||||
console.log(` ${color(GREEN, '✓')} ${text}`);
|
||||
}
|
||||
|
||||
function warn(text) {
|
||||
console.log(` ${color(YELLOW, '⚠')} ${text}`);
|
||||
}
|
||||
|
||||
function error(text) {
|
||||
console.log(` ${color(RED, '✗')} ${text}`);
|
||||
}
|
||||
|
||||
function info(text) {
|
||||
console.log(` ${color(CYAN, '→')} ${text}`);
|
||||
}
|
||||
|
||||
function dim(text) {
|
||||
console.log(` ${color(DIM, text)}`);
|
||||
}
|
||||
|
||||
function section(title) {
|
||||
console.log(`\n ${color(BOLD, title)}:`);
|
||||
}
|
||||
|
||||
// ─── Platform Detection ─────────────────────────────────────────────────────
|
||||
|
||||
const platform = os.platform(); // 'win32' | 'darwin' | 'linux'
|
||||
const arch = os.arch();
|
||||
|
||||
/** Detect Linux distribution from os-release */
|
||||
function detectLinuxDistro() {
|
||||
try {
|
||||
const osRelease = fs.readFileSync('/etc/os-release', 'utf8');
|
||||
const idMatch = osRelease.match(/^ID="?([^"\n]+)"?/m);
|
||||
const idLikeMatch = osRelease.match(/^ID_LIKE="?([^"\n]+)"?/m);
|
||||
|
||||
const id = idMatch ? idMatch[1].toLowerCase() : '';
|
||||
const idLike = idLikeMatch ? idLikeMatch[1].toLowerCase() : '';
|
||||
|
||||
if (id === 'ubuntu' || id === 'debian' || idLike.includes('debian')) {
|
||||
return 'debian';
|
||||
}
|
||||
if (id === 'arch' || id === 'manjaro' || idLike.includes('arch')) {
|
||||
return 'arch';
|
||||
}
|
||||
if (id === 'fedora' || idLike.includes('fedora') || idLike.includes('rhel')) {
|
||||
return 'fedora';
|
||||
}
|
||||
return id || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool Checkers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Check if a command exists on PATH */
|
||||
function commandExists(cmd) {
|
||||
try {
|
||||
const whereCmd = platform === 'win32' ? 'where' : 'which';
|
||||
execSync(`${whereCmd} "${cmd}"`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if running with administrator/sudo privileges */
|
||||
function hasAdminPrivileges() {
|
||||
if (platform === 'win32') {
|
||||
try {
|
||||
// On Windows, check via net session (requires admin)
|
||||
execSync('net session', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// On Unix, check if UID is 0
|
||||
return process.getuid ? process.getuid() === 0 : false;
|
||||
}
|
||||
|
||||
/** Check Windows: Visual Studio Build Tools */
|
||||
function checkWindows() {
|
||||
const results = { ok: true, missing: [] };
|
||||
|
||||
// Check for cl.exe (C++ compiler from VS Build Tools)
|
||||
if (commandExists('cl.exe')) {
|
||||
success('MSVC Compiler (cl.exe) found');
|
||||
} else {
|
||||
// Try to find VS installation via vswhere or registry
|
||||
let vsFound = false;
|
||||
|
||||
// vswhere.exe (shipped with VS 2017+)
|
||||
const vswherePaths = [
|
||||
'C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe',
|
||||
'C:\\Program Files\\Microsoft Visual Studio\\Installer\\vswhere.exe',
|
||||
];
|
||||
|
||||
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();
|
||||
|
||||
if (output) {
|
||||
vsFound = true;
|
||||
success(`Visual Studio found at: ${output}`);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// vswhere not found, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check registry fallback
|
||||
if (!vsFound) {
|
||||
try {
|
||||
execSync(
|
||||
'reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0',
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
vsFound = true;
|
||||
success('Visual Studio 2022 found (registry)');
|
||||
} catch {
|
||||
// Not found in registry either
|
||||
}
|
||||
}
|
||||
|
||||
if (!vsFound) {
|
||||
error('MSVC Build Tools not found');
|
||||
results.ok = false;
|
||||
results.missing.push('msvc');
|
||||
}
|
||||
}
|
||||
|
||||
// Check Python (needed by node-gyp)
|
||||
if (commandExists('python') || commandExists('python3')) {
|
||||
success('Python found');
|
||||
} else {
|
||||
error('Python not found (required by node-gyp)');
|
||||
results.ok = false;
|
||||
results.missing.push('python');
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Check macOS: Xcode Command Line Tools */
|
||||
function checkMacOS() {
|
||||
const results = { ok: true, missing: [] };
|
||||
|
||||
try {
|
||||
const xcodePath = execSync('xcode-select -p', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
|
||||
if (xcodePath) {
|
||||
success(`Xcode CLT found at: ${xcodePath}`);
|
||||
}
|
||||
} catch {
|
||||
error('Xcode Command Line Tools not found');
|
||||
results.ok = false;
|
||||
results.missing.push('xcode-clt');
|
||||
}
|
||||
|
||||
if (commandExists('make') || commandExists('gmake')) {
|
||||
success('make found');
|
||||
} else {
|
||||
error('make not found');
|
||||
results.ok = false;
|
||||
results.missing.push('make');
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Check Linux: build-essential, python3, libsecret */
|
||||
function checkLinux() {
|
||||
const results = { ok: true, missing: [] };
|
||||
const distro = detectLinuxDistro();
|
||||
|
||||
section(`Linux Distribution Detected: ${distro || 'unknown'}`);
|
||||
|
||||
// gcc / g++
|
||||
if (commandExists('gcc') || commandExists('cc')) {
|
||||
success('C compiler found');
|
||||
} else {
|
||||
error('C compiler not found');
|
||||
results.ok = false;
|
||||
results.missing.push('compiler');
|
||||
}
|
||||
|
||||
if (commandExists('g++') || commandExists('c++')) {
|
||||
success('C++ compiler found');
|
||||
} else {
|
||||
error('C++ compiler not found');
|
||||
results.ok = false;
|
||||
results.missing.push('compiler');
|
||||
}
|
||||
|
||||
// make
|
||||
if (commandExists('make')) {
|
||||
success('make found');
|
||||
} else {
|
||||
error('make not found');
|
||||
results.ok = false;
|
||||
results.missing.push('make');
|
||||
}
|
||||
|
||||
// Python 3
|
||||
if (commandExists('python3')) {
|
||||
success('Python 3 found');
|
||||
} else if (commandExists('python')) {
|
||||
// Check if python is python3
|
||||
try {
|
||||
const ver = execSync('python --version', { encoding: 'utf8' });
|
||||
if (ver.includes('3.')) {
|
||||
success('Python 3 found (as python)');
|
||||
} else {
|
||||
error('Python 3 not found (python is v2)');
|
||||
results.ok = false;
|
||||
results.missing.push('python3');
|
||||
}
|
||||
} catch {
|
||||
error('Python not found');
|
||||
results.ok = false;
|
||||
results.missing.push('python3');
|
||||
}
|
||||
} else {
|
||||
error('Python 3 not found');
|
||||
results.ok = false;
|
||||
results.missing.push('python3');
|
||||
}
|
||||
|
||||
// libsecret (needed by keytar)
|
||||
try {
|
||||
if (distro === 'debian') {
|
||||
execSync('dpkg -s libsecret-1-dev', { stdio: 'ignore' });
|
||||
success('libsecret-1-dev found');
|
||||
} else if (distro === 'arch') {
|
||||
execSync('pacman -Qi libsecret', { stdio: 'ignore' });
|
||||
success('libsecret found');
|
||||
} else {
|
||||
// Check for pkg-config
|
||||
try {
|
||||
execSync('pkg-config --exists libsecret-1', { stdio: 'ignore' });
|
||||
success('libsecret (pkg-config) found');
|
||||
} catch {
|
||||
warn('libsecret not detected (may not be needed if using prebuilt keytar)');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
warn('libsecret not detected (may not be needed if using prebuilt keytar)');
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Installation Helpers ───────────────────────────────────────────────────
|
||||
|
||||
/** Run a command and stream output, return exit code */
|
||||
function runCommand(cmd, args, opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
console.log(`\n ${color(CYAN, 'Running:')} ${color(DIM, `${cmd} ${args.join(' ')}`)}`);
|
||||
|
||||
const child = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
shell: platform === 'win32',
|
||||
...opts,
|
||||
});
|
||||
|
||||
resolve(child.status);
|
||||
});
|
||||
}
|
||||
|
||||
/** Install Windows: Visual Studio 2022 Build Tools via winget */
|
||||
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('');
|
||||
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"'));
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
info('Attempting automatic installation of Visual Studio 2022 Build Tools...');
|
||||
dim('This may take several minutes and requires administrator privileges.');
|
||||
console.log('');
|
||||
|
||||
if (!hasAdminPrivileges()) {
|
||||
warn('Not running as Administrator. winget may prompt for elevation.');
|
||||
}
|
||||
|
||||
const status = await runCommand('winget', [
|
||||
'install',
|
||||
'Microsoft.VisualStudio.2022.BuildTools',
|
||||
'--override',
|
||||
'"--quiet --wait --norestart --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --includeRecommended"',
|
||||
]);
|
||||
|
||||
if (status === 0) {
|
||||
success('Visual Studio 2022 Build Tools installed successfully.');
|
||||
console.log('');
|
||||
warn('You may need to restart your terminal for PATH changes to take effect.');
|
||||
return true;
|
||||
}
|
||||
|
||||
error('Automatic installation failed. Please install manually.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Install macOS: Xcode Command Line Tools */
|
||||
async function installMacOS() {
|
||||
console.log('');
|
||||
info('Installing Xcode Command Line Tools...');
|
||||
dim('A GUI dialog will appear. Click "Install" to proceed.');
|
||||
|
||||
const status = await runCommand('xcode-select', ['--install']);
|
||||
|
||||
// xcode-select --install returns 1 if already installed or cancelled
|
||||
if (status === 0) {
|
||||
success('Xcode Command Line Tools installation initiated.');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if already installed (common false-negative)
|
||||
try {
|
||||
execSync('xcode-select -p', { stdio: 'ignore' });
|
||||
success('Xcode Command Line Tools are already installed.');
|
||||
return true;
|
||||
} catch {
|
||||
warn('Xcode CLT installation was cancelled or failed.');
|
||||
warn('You can install manually via: xcode-select --install');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Install Linux: distribution-specific packages */
|
||||
async function installLinux() {
|
||||
const distro = detectLinuxDistro();
|
||||
|
||||
if (distro === 'debian') {
|
||||
// Ubuntu / Debian
|
||||
if (!hasAdminPrivileges()) {
|
||||
warn('Root privileges required. You may be prompted for your password.');
|
||||
}
|
||||
|
||||
const status = await runCommand('sudo', [
|
||||
'apt-get', 'install', '-y',
|
||||
'build-essential',
|
||||
'python3',
|
||||
'libsecret-1-dev',
|
||||
'pkg-config',
|
||||
]);
|
||||
|
||||
if (status === 0) {
|
||||
success('Build dependencies installed successfully.');
|
||||
return true;
|
||||
}
|
||||
|
||||
error('Failed to install build dependencies.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (distro === 'arch') {
|
||||
// Manjaro / Arch
|
||||
if (!hasAdminPrivileges()) {
|
||||
warn('Root privileges required. You may be prompted for your password.');
|
||||
}
|
||||
|
||||
const status = await runCommand('sudo', [
|
||||
'pacman', '-S', '--needed', '--noconfirm',
|
||||
'base-devel',
|
||||
'python',
|
||||
'libsecret',
|
||||
'pkg-config',
|
||||
]);
|
||||
|
||||
if (status === 0) {
|
||||
success('Build dependencies installed successfully.');
|
||||
return true;
|
||||
}
|
||||
|
||||
error('Failed to install build dependencies.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (distro === 'fedora') {
|
||||
if (!hasAdminPrivileges()) {
|
||||
warn('Root privileges required. You may be prompted for your password.');
|
||||
}
|
||||
|
||||
const status = await runCommand('sudo', [
|
||||
'dnf', 'install', '-y',
|
||||
'gcc-c++',
|
||||
'make',
|
||||
'python3',
|
||||
'libsecret-devel',
|
||||
'pkg-config',
|
||||
]);
|
||||
|
||||
if (status === 0) {
|
||||
success('Build dependencies installed successfully.');
|
||||
return true;
|
||||
}
|
||||
|
||||
error('Failed to install build dependencies.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unknown distro - provide generic instructions
|
||||
console.log('');
|
||||
warn(`Unknown Linux distribution: ${distro}`);
|
||||
console.log('');
|
||||
console.log(' You need the following packages to compile native Node.js modules:');
|
||||
console.log(' - C/C++ compiler (gcc, g++)');
|
||||
console.log(' - GNU Make');
|
||||
console.log(' - Python 3');
|
||||
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.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const checkOnly = args.includes('--check-only') || args.includes('--check');
|
||||
|
||||
console.log('');
|
||||
console.log(color(BOLD + CYAN, '┌──────────────────────────────────────────┐'));
|
||||
console.log(color(BOLD + CYAN, '│ Gemini Nexus - Dev Environment Setup │'));
|
||||
console.log(color(BOLD + CYAN, '└──────────────────────────────────────────┘'));
|
||||
console.log('');
|
||||
dim(`Platform: ${platform} (${arch})`);
|
||||
dim(`Node.js: ${process.version}`);
|
||||
|
||||
// Step 1: Check current state
|
||||
header('Checking Build Toolchain');
|
||||
|
||||
let results;
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
results = checkWindows();
|
||||
break;
|
||||
case 'darwin':
|
||||
results = checkMacOS();
|
||||
break;
|
||||
case 'linux':
|
||||
results = checkLinux();
|
||||
break;
|
||||
default:
|
||||
console.log('');
|
||||
error(`Unsupported platform: ${platform}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log('');
|
||||
if (results.ok) {
|
||||
success('All build tools are present and ready.');
|
||||
console.log('');
|
||||
dim('You can now run: npm install && npm start');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Missing tools ──
|
||||
console.log('');
|
||||
error('Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.');
|
||||
|
||||
if (checkOnly) {
|
||||
console.log('');
|
||||
warn('Build tools are missing. Native modules (better-sqlite3, keytar) require compilation.');
|
||||
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.');
|
||||
// Don't exit with error - allow npm install to succeed (prebuilt binaries may work)
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Attempt installation
|
||||
header('Installing Build Dependencies');
|
||||
|
||||
let installed = false;
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
installed = await installWindows();
|
||||
break;
|
||||
case 'darwin':
|
||||
installed = await installMacOS();
|
||||
break;
|
||||
case 'linux':
|
||||
installed = await installLinux();
|
||||
break;
|
||||
}
|
||||
|
||||
// Step 3: Final status
|
||||
console.log('');
|
||||
if (installed) {
|
||||
success('Setup complete. You can now run: npm install && npm start');
|
||||
console.log('');
|
||||
dim('If you encounter issues, restart your terminal and try again.');
|
||||
} else {
|
||||
console.log('');
|
||||
warn('Automatic installation was not successful.');
|
||||
warn('Please install the required tools manually, then run npm install again.');
|
||||
printManualInstructions(platform, results.missing);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printManualInstructions(platform, missing) {
|
||||
console.log('');
|
||||
console.log(color(BOLD, ' ── Manual Installation Instructions ──'));
|
||||
console.log('');
|
||||
|
||||
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('');
|
||||
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('');
|
||||
console.log(' 4. Restart your terminal after installation.');
|
||||
break;
|
||||
|
||||
case 'darwin':
|
||||
console.log(' Run this command in your terminal:');
|
||||
console.log(color(DIM, ' xcode-select --install'));
|
||||
console.log('');
|
||||
console.log(' Then click "Install" in the dialog that appears.');
|
||||
break;
|
||||
|
||||
case 'linux': {
|
||||
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'));
|
||||
} else if (distro === 'arch') {
|
||||
console.log(' Run:');
|
||||
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'));
|
||||
} else {
|
||||
console.log(' Install: C/C++ compiler, make, python3, libsecret-dev, pkg-config');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ─── Run ────────────────────────────────────────────────────────────────────
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(color(RED, `\nUnexpected error: ${err.message}`));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { syncWithLocalTheme } from './actions/theme';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -29,12 +29,10 @@ const queryClient = new QueryClient();
|
||||
|
||||
const root = createRoot(document.getElementById('app')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="system">
|
||||
<App />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="dark">
|
||||
<App />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
After Width: | Height: | Size: 875 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 736 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 736 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 44 KiB |
@@ -114,8 +114,8 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden transition-all duration-200',
|
||||
'hover:shadow-primary/10 hover:shadow-xl',
|
||||
isCurrent ? 'border-primary/50 bg-primary/5' : 'hover:border-primary/20 cursor-pointer',
|
||||
|
||||
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)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCloudAccounts, useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -67,7 +67,7 @@ function AccountProxyItem({ account }: { account: CloudAccount }) {
|
||||
/>
|
||||
<div className="w-16">
|
||||
{proxySaved && (
|
||||
<span className="text-[10px] whitespace-nowrap text-green-500">
|
||||
<span className="text-[10px] whitespace-nowrap text-emerald-400">
|
||||
{t('cloud.card.proxySaved')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react';
|
||||
@@ -59,9 +58,9 @@ const MODEL_DISPLAY_REPLACEMENTS: Array<[string, string]> = [
|
||||
];
|
||||
|
||||
const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
high: 'text-green-500',
|
||||
medium: 'text-yellow-500',
|
||||
low: 'text-red-500',
|
||||
high: 'text-emerald-400',
|
||||
medium: 'text-amber-400',
|
||||
low: 'text-rose-400',
|
||||
};
|
||||
|
||||
const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
@@ -241,12 +240,12 @@ export function CloudAccountCard({
|
||||
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase">
|
||||
{title}
|
||||
</span>
|
||||
<div className="bg-border/50 h-[1px] flex-1" />
|
||||
<div className="h-[1px] flex-1 bg-white/[0.04]" />
|
||||
</div>
|
||||
{models.map(([modelName, info]) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="group/item hover:bg-muted/60 hover:border-border/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-sm transition-all"
|
||||
className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-[13px] transition-all hover:bg-white/[0.03]"
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
|
||||
@@ -292,7 +291,7 @@ export function CloudAccountCard({
|
||||
const providerGroupedQuotaSection =
|
||||
providerStats && providerStats.visibleModels > 0 ? (
|
||||
<>
|
||||
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
|
||||
<div className="flex items-center justify-between rounded-lg bg-white/[0.03] px-3 py-1.5 text-xs">
|
||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
@@ -344,7 +343,7 @@ export function CloudAccountCard({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
||||
className={`group flex h-full flex-col overflow-hidden rounded-xl border border-white/[0.06] bg-card transition-all duration-200 hover:border-white/[0.12] ${isSelected ? 'ring-accent/30 border-accent/40 ring-2' : ''}`}
|
||||
>
|
||||
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
||||
{onToggleSelection && (
|
||||
@@ -366,7 +365,7 @@ export function CloudAccountCard({
|
||||
className="bg-muted h-10 w-10 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full border">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/[0.06] text-muted-foreground">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
@@ -494,7 +493,7 @@ export function CloudAccountCard({
|
||||
{account.provider.toUpperCase()}
|
||||
</Badge>
|
||||
{account.is_active && (
|
||||
<Badge variant="default" className="bg-green-500 text-xs hover:bg-green-600">
|
||||
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-xs hover:bg-emerald-500/20">
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -515,7 +514,7 @@ export function CloudAccountCard({
|
||||
)}
|
||||
|
||||
{account.is_active ? (
|
||||
<Button variant="ghost" size="sm" disabled className="text-green-600 opacity-100">
|
||||
<Button variant="ghost" size="sm" disabled className="text-emerald-400 opacity-100">
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{t('cloud.card.active')}
|
||||
</Button>
|
||||
@@ -552,7 +551,7 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="bg-muted/20 text-muted-foreground justify-center border-t p-2 px-4 text-xs">
|
||||
<CardFooter className="justify-center border-t border-white/[0.04] p-2 px-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t('cloud.card.used')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
@@ -614,7 +613,7 @@ export function CompactCloudAccountCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200">
|
||||
<div className="group flex items-center gap-3 rounded-xl border border-white/[0.06] bg-card px-3 py-2 transition-all duration-200 hover:border-white/[0.12]">
|
||||
{account.avatar_url ? (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
@@ -622,14 +621,14 @@ export function CompactCloudAccountCard({
|
||||
className="bg-muted h-7 w-7 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-white/[0.06] text-xs font-semibold text-muted-foreground">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
<span className="truncate text-[13px] font-semibold">
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</span>
|
||||
<Badge
|
||||
@@ -645,7 +644,7 @@ export function CompactCloudAccountCard({
|
||||
{account.is_active && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="shrink-0 bg-green-500 text-[10px] hover:bg-green-600"
|
||||
className="shrink-0 bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-[10px] hover:bg-emerald-500/20"
|
||||
>
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
@@ -757,7 +756,7 @@ export function CompactCloudAccountCard({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
className="h-7 text-xs text-green-600 opacity-100"
|
||||
className="h-7 text-xs text-emerald-400 opacity-100"
|
||||
>
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{t('cloud.card.active')}
|
||||
|
||||
@@ -85,10 +85,10 @@ import { shouldAutoSubmitGoogleAuthCode } from '@/utils/googleAuthSubmission';
|
||||
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact';
|
||||
|
||||
const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
|
||||
auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
|
||||
'2-col': 'grid gap-4 grid-cols-2',
|
||||
'3-col': 'grid gap-4 grid-cols-3',
|
||||
list: 'grid gap-4 grid-cols-1',
|
||||
auto: 'grid gap-3 md:grid-cols-2 xl:grid-cols-3',
|
||||
'2-col': 'grid gap-3 grid-cols-2',
|
||||
'3-col': 'grid gap-3 grid-cols-3',
|
||||
list: 'grid gap-3 grid-cols-1',
|
||||
compact: 'flex flex-col gap-2',
|
||||
};
|
||||
|
||||
@@ -99,9 +99,9 @@ const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
};
|
||||
|
||||
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
high: 'text-emerald-600 dark:text-emerald-400',
|
||||
medium: 'text-amber-600 dark:text-amber-400',
|
||||
low: 'text-rose-600 dark:text-rose-400',
|
||||
high: 'text-emerald-400',
|
||||
medium: 'text-amber-400',
|
||||
low: 'text-rose-400',
|
||||
};
|
||||
|
||||
export function CloudAccountList() {
|
||||
@@ -259,16 +259,12 @@ export function CloudAccountList() {
|
||||
return;
|
||||
}
|
||||
const activeClientKey = oauthClients.find((client) => client.is_active)?.key;
|
||||
if (activeClientKey) {
|
||||
setSelectedOAuthClientKey(activeClientKey);
|
||||
}
|
||||
if (activeClientKey && activeClientKey !== selectedOAuthClientKey) { setTimeout(() => setSelectedOAuthClientKey(activeClientKey), 0); }
|
||||
}, [oauthClients, selectedOAuthClientKey]);
|
||||
// Listen for Google Auth Code
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
console.log('[OAuth] Registering Google auth code IPC listener');
|
||||
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
||||
console.log('[OAuth] Received Google auth code via IPC:', code?.substring(0, 10) + '...');
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
setAuthCode(code);
|
||||
});
|
||||
@@ -286,7 +282,6 @@ export function CloudAccountList() {
|
||||
lastSubmittedAuthCode: lastSubmittedAuthCodeRef.current,
|
||||
})
|
||||
) {
|
||||
console.log('[OAuth] Auto-submitting Google auth code');
|
||||
submitAuthCode(authCode);
|
||||
}
|
||||
}, [addMutation.isPending, authCode, isAddDialogOpen, submitAuthCode]);
|
||||
@@ -308,7 +303,6 @@ export function CloudAccountList() {
|
||||
}, [error, errorUpdatedAt, isError, t, toast]);
|
||||
|
||||
const handleRefresh = (id: string) => {
|
||||
console.log(`[Renderer] Triggering refresh for: ${id}`);
|
||||
refreshMutation.mutate(
|
||||
{ accountId: id },
|
||||
{
|
||||
@@ -669,34 +663,34 @@ export function CloudAccountList() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-20">
|
||||
<div className="bg-card rounded-lg border p-5">
|
||||
<div className="rounded-xl border border-white/[0.06] p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('cloud.title')}</h2>
|
||||
<p className="text-muted-foreground max-w-2xl">{t('cloud.description')}</p>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{t('cloud.title')}</h2>
|
||||
<p className="text-muted-foreground max-w-2xl text-[13px]">{t('cloud.description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">
|
||||
{t('cloud.card.actions')}
|
||||
</div>
|
||||
<div className="text-base font-semibold">{totalAccounts}</div>
|
||||
<div className="text-sm font-semibold tabular-nums">{totalAccounts}</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">
|
||||
{t('cloud.card.active')}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-emerald-600">{activeAccounts}</div>
|
||||
<div className="text-sm font-semibold text-emerald-400">{activeAccounts}</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">
|
||||
{t('cloud.card.rateLimited')}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-rose-600">{rateLimitedAccounts}</div>
|
||||
<div className="text-sm font-semibold text-rose-400">{rateLimitedAccounts}</div>
|
||||
</div>
|
||||
{/* Global Quota */}
|
||||
{overallQuotaPercentage !== null && (
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">
|
||||
{t('cloud.globalQuota')}
|
||||
</div>
|
||||
@@ -719,13 +713,13 @@ export function CloudAccountList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card flex flex-wrap items-center gap-2 rounded-lg border p-3">
|
||||
<div className="bg-muted/50 flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-white/[0.06] bg-card p-2.5">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-white/[0.06] px-3 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
|
||||
<Label htmlFor="auto-switch" className="cursor-pointer text-[13px] font-medium">
|
||||
{t('cloud.autoSwitch')}
|
||||
</Label>
|
||||
</div>
|
||||
@@ -1114,7 +1108,7 @@ export function CloudAccountList() {
|
||||
)}
|
||||
|
||||
{sortedAccounts.length === 0 && (
|
||||
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center">
|
||||
<div className="text-muted-foreground col-span-full rounded-xl border border-dashed border-white/[0.1] py-14 text-center">
|
||||
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
||||
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
||||
</div>
|
||||
@@ -1123,7 +1117,7 @@ export function CloudAccountList() {
|
||||
|
||||
{/* Batch Action Bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="bg-card animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border px-6 py-2 shadow-lg">
|
||||
<div className="bg-card animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border border-white/[0.1] px-6 py-2 shadow-2xl shadow-black/30">
|
||||
<div className="flex items-center gap-2 border-r pr-4">
|
||||
<span className="text-sm font-semibold">
|
||||
{t('cloud.batch.selected', { count: selectedIds.size })}
|
||||
|
||||
@@ -134,8 +134,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
void refreshProfiles();
|
||||
}, [open, refreshProfiles]);
|
||||
setTimeout(() => { void refreshProfiles(); }, 0); }, [open, refreshProfiles]);
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||
if (actionLockRef.current) {
|
||||
@@ -344,7 +343,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
|
||||
{!showLoadingPlaceholder
|
||||
? history.map((version) => (
|
||||
<div key={version.id} className="bg-muted/20 rounded-xl border 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,12 +48,8 @@ export function ModelVisibilitySettings() {
|
||||
|
||||
// Initialize model visibility and provider groupings from config
|
||||
useEffect(() => {
|
||||
if (config?.model_visibility) {
|
||||
setModelVisibilityMap(config.model_visibility);
|
||||
}
|
||||
if (config?.provider_groupings_enabled !== undefined) {
|
||||
setProviderGroupingEnabled(config.provider_groupings_enabled);
|
||||
}
|
||||
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
|
||||
@@ -188,7 +184,7 @@ export function ModelVisibilitySettings() {
|
||||
return (
|
||||
<div
|
||||
key={modelId}
|
||||
className="hover:bg-muted/50 flex items-center space-x-3 rounded p-2"
|
||||
className="flex items-center space-x-3 rounded-lg p-2 hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
id={`model-${modelId}`}
|
||||
@@ -210,7 +206,7 @@ export function ModelVisibilitySettings() {
|
||||
title={t('proxy.copy')}
|
||||
>
|
||||
{copiedModel === modelId ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<CheckCircle className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
|
||||
@@ -37,12 +37,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border/60 overflow-hidden rounded-lg border">
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
{/* Provider Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className="hover:bg-muted/60 flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm transition-colors"
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-[13px] transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
@@ -87,7 +87,7 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(avgPercentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(avgPercentage)}%` }}
|
||||
@@ -98,12 +98,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
|
||||
{/* Individual model rows (shown when expanded) */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-border/40 border-t">
|
||||
<div className="border-t border-white/[0.06]">
|
||||
{visibleModels.map((model, index) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`hover:bg-muted/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 pl-9 text-sm transition-colors ${
|
||||
index < visibleModels.length - 1 ? 'border-border/20 border-b' : ''
|
||||
className={`grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 pl-9 text-[13px] transition-colors hover:bg-white/[0.03] ${
|
||||
index < visibleModels.length - 1 ? 'border-b border-white/[0.04]' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted-foreground min-w-0 truncate" title={model.id}>
|
||||
@@ -126,7 +126,7 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
<span className="text-muted-foreground text-[10px]">{leftLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
|
||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(model.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(model.percentage)}%` }}
|
||||
|
||||
@@ -52,36 +52,38 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || isPending}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-lg transition-all duration-300',
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
isRunning
|
||||
? 'bg-green-100/50 text-green-700 hover:bg-green-100 hover:text-green-800 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
||||
: 'bg-red-100/50 text-red-700 hover:bg-red-100 hover:text-red-800 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50',
|
||||
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
||||
: 'bg-white/[0.04] text-muted-foreground hover:bg-white/[0.08] hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Power className="h-5 w-5" />
|
||||
<Power className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-2">
|
||||
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
|
||||
<div
|
||||
className={cn('h-2 w-2 rounded-full', isRunning ? 'bg-green-500' : 'bg-red-500')}
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
isRunning ? 'bg-emerald-400' : 'bg-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
<p>
|
||||
<span>
|
||||
{isLoading
|
||||
? t('status.checking')
|
||||
: isRunning
|
||||
? t('status.running')
|
||||
: t('status.stopped')}
|
||||
</p>
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -93,23 +95,33 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between overflow-hidden rounded-lg px-3 py-2.5 text-sm font-medium ring-1 transition-colors ring-inset',
|
||||
'flex items-center justify-between rounded-lg px-3 py-2 transition-all duration-200',
|
||||
isRunning
|
||||
? 'bg-green-100 text-green-900 ring-green-200 dark:bg-green-900/20 dark:text-green-100 dark:ring-green-900/50'
|
||||
: 'bg-red-100 text-red-900 ring-red-200 dark:bg-red-900/20 dark:text-red-100 dark:ring-red-900/50',
|
||||
? 'bg-emerald-500/[0.08]'
|
||||
: 'bg-white/[0.02]',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isLoading ? (
|
||||
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" />
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full transition-colors duration-300',
|
||||
isRunning
|
||||
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
|
||||
: 'bg-muted-foreground/50',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 leading-none">
|
||||
<span className="text-xs font-semibold tracking-wider uppercase opacity-80">Status</span>
|
||||
<span className="text-sm leading-tight font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Status
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-xs font-medium leading-tight',
|
||||
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
|
||||
)}>
|
||||
{isLoading
|
||||
? t('status.checking')
|
||||
: isRunning
|
||||
@@ -124,22 +136,20 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || isPending}
|
||||
className={cn(
|
||||
'ml-2 h-8 shrink-0 rounded-md border px-3 transition-all',
|
||||
'h-7 gap-1.5 rounded-md px-2.5 text-xs',
|
||||
isRunning
|
||||
? 'border-green-200 bg-green-200/50 text-green-900 hover:bg-green-200 dark:border-green-800 dark:bg-green-800/30 dark:text-green-100 dark:hover:bg-green-800/50'
|
||||
: 'border-red-200 bg-red-200/50 text-red-900 hover:bg-red-200 dark:border-red-800 dark:bg-red-800/30 dark:text-red-100 dark:hover:bg-red-800/50',
|
||||
? 'text-emerald-400 hover:bg-emerald-500/10'
|
||||
: 'text-muted-foreground hover:bg-white/[0.06]',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 fill-current" />
|
||||
<Play className="h-3 w-3 fill-current" />
|
||||
)}
|
||||
<span className="ml-2 font-semibold">
|
||||
{isRunning ? t('action.stop') : t('action.start')}
|
||||
</span>
|
||||
<span>{isRunning ? t('action.stop') : t('action.start')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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-gray-400 select-none">
|
||||
<div className="flex flex-1 p-2 text-xs whitespace-nowrap text-muted-foreground select-none">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { MonitorTab } from './advanced/MonitorTab';
|
||||
import { ToolsTab } from './advanced/ToolsTab';
|
||||
import { ReplayTab } from './advanced/ReplayTab';
|
||||
import { CapabilitiesTab } from './advanced/CapabilitiesTab';
|
||||
import type { ProxyConfig } from '@/types/config';
|
||||
|
||||
interface ProxyAdvancedTabProps {
|
||||
proxyConfig: ProxyConfig;
|
||||
}
|
||||
|
||||
export const ProxyAdvancedTab: React.FC<ProxyAdvancedTabProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="monitor" className="space-y-5">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="monitor">{t('proxy.advanced.monitor')}</TabsTrigger>
|
||||
<TabsTrigger value="tools">{t('proxy.advanced.tabs.tools')}</TabsTrigger>
|
||||
<TabsTrigger value="replay">{t('proxy.advanced.tabs.replay')}</TabsTrigger>
|
||||
<TabsTrigger value="capabilities">{t('proxy.advanced.tabs.capabilities')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="monitor">
|
||||
<MonitorTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="tools">
|
||||
<ToolsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="replay">
|
||||
<ReplayTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="capabilities">
|
||||
<CapabilitiesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Eye, Zap, Code, Brain, Star, Layers } from 'lucide-react';
|
||||
|
||||
interface ModelCapability {
|
||||
id: string;
|
||||
object: string;
|
||||
displayName?: string;
|
||||
capabilities: {
|
||||
vision: boolean;
|
||||
thinking: boolean;
|
||||
streaming: boolean;
|
||||
jsonMode: boolean;
|
||||
recommended: boolean;
|
||||
};
|
||||
limits: {
|
||||
maxTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
thinkingBudget?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const CAPABILITY_ICONS = {
|
||||
vision: Eye,
|
||||
thinking: Brain,
|
||||
streaming: Zap,
|
||||
jsonMode: Code,
|
||||
recommended: Star,
|
||||
} as const;
|
||||
|
||||
const CAPABILITY_LABELS = {
|
||||
vision: 'Vision',
|
||||
thinking: 'Thinking',
|
||||
streaming: 'Streaming',
|
||||
jsonMode: 'JSON Mode',
|
||||
recommended: 'Recommended',
|
||||
} as const;
|
||||
|
||||
function getProvider(modelId: string): string {
|
||||
const id = modelId.toLowerCase();
|
||||
if (id.startsWith('gemini')) return 'Google';
|
||||
if (id.startsWith('claude')) return 'Anthropic';
|
||||
if (id.startsWith('gpt') || id.startsWith('o1') || id.startsWith('o3') || id.startsWith('text-'))
|
||||
return 'OpenAI';
|
||||
if (id.startsWith('deepseek')) return 'DeepSeek';
|
||||
if (id.startsWith('meta-llama') || id.startsWith('llama')) return 'Meta';
|
||||
if (id.startsWith('mistral') || id.startsWith('mixtral')) return 'Mistral';
|
||||
if (id.startsWith('qwen')) return 'Alibaba';
|
||||
if (id.startsWith('x-ai') || id.startsWith('grok')) return 'xAI';
|
||||
if (id.startsWith('sonar')) return 'Perplexity';
|
||||
if (id.startsWith('command')) return 'Cohere';
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
/** Format large token counts with K/M suffixes */
|
||||
function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(0)}K`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
export const CapabilitiesTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: capabilitiesData, isLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'modelCapabilities'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getModelCapabilities(),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const models: ModelCapability[] = capabilitiesData?.success
|
||||
? (capabilitiesData.data as ModelCapability[])
|
||||
: [];
|
||||
|
||||
const groupedModels = useMemo(() => {
|
||||
return groupBy(models, (m) => getProvider(m.id));
|
||||
}, [models]);
|
||||
|
||||
const sortedProviders = Object.keys(groupedModels).sort((a, b) => {
|
||||
if (a === 'Other') return 1;
|
||||
if (b === 'Other') return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">
|
||||
{t('proxy.advanced.capabilities.title', 'Model Capabilities')}
|
||||
</h3>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}
|
||||
</p>
|
||||
</div>
|
||||
{models.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs tabular-nums">
|
||||
<Layers className="mr-1.5 h-3 w-3" />
|
||||
{models.length} models
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{sortedProviders.map((provider) => (
|
||||
<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">
|
||||
{provider}
|
||||
</h4>
|
||||
<div className="h-px flex-1 bg-white/[0.06]" />
|
||||
<span className="text-[11px] text-muted-foreground/60 tabular-nums">
|
||||
{groupedModels[provider].length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Model cards */}
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{groupedModels[provider].map((model) => {
|
||||
const enabledCount = Object.values(model.capabilities).filter(Boolean).length;
|
||||
const totalCount = Object.values(model.capabilities).length;
|
||||
const hasLimits = Object.values(model.limits).some((v) => v != null);
|
||||
|
||||
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]"
|
||||
>
|
||||
{/* 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"
|
||||
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">
|
||||
{enabledCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-mono text-[11px] text-muted-foreground truncate"
|
||||
title={model.id}
|
||||
>
|
||||
{model.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Capability pills */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{Object.entries(model.capabilities).map(([key, supported]) => {
|
||||
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
||||
const Icon = CAPABILITY_ICONS[iconKey];
|
||||
const label = CAPABILITY_LABELS[iconKey];
|
||||
return (
|
||||
<div
|
||||
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]'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Limits section */}
|
||||
{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">
|
||||
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">
|
||||
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">
|
||||
THINK {formatTokenCount(model.limits.thinkingBudget)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, Play, RefreshCw, Clock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface ReplayEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
method: string;
|
||||
endpoint: string;
|
||||
status?: number;
|
||||
duration?: number;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export const ReplayTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: requestsData,
|
||||
isLoading: requestsLoading,
|
||||
refetch: refetchRequests,
|
||||
} = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'recentRequests'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getRecentRequests(),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const replayMutation = useMutation({
|
||||
mutationFn: (requestId: string) => ipc.client.proxyAdvanced.replayRequest({ requestId }),
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: t('proxy.advanced.replay.success'),
|
||||
description: t('proxy.advanced.replay.replayedSuccessfully'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('proxy.advanced.replay.error'),
|
||||
description: data.error || t('proxy.advanced.replay.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
setSelectedRequestId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('proxy.advanced.replay.error'),
|
||||
description: error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
setSelectedRequestId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const requests: ReplayEntry[] = requestsData?.success ? (requestsData.data as ReplayEntry[]) : [];
|
||||
|
||||
const handleReplay = (requestId: string) => {
|
||||
setSelectedRequestId(requestId);
|
||||
replayMutation.mutate(requestId);
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
};
|
||||
|
||||
const getStatusBadge = (status?: number) => {
|
||||
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">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status >= 400) {
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<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>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchRequests()}
|
||||
disabled={requestsLoading}
|
||||
>
|
||||
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${requestsLoading ? 'animate-spin' : ''}`} />
|
||||
{t('proxy.advanced.replay.refresh')}
|
||||
</Button>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{requestsLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</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">
|
||||
{t('proxy.advanced.replay.noRequests')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{requests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3 transition-colors hover:bg-white/[0.02]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(request.status)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[13px] font-medium">
|
||||
{request.method} {request.endpoint}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(request.timestamp)}
|
||||
{request.duration && ` · ${request.duration}ms`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReplay(request.id)}
|
||||
disabled={replayMutation.isPending && selectedRequestId === request.id}
|
||||
>
|
||||
{replayMutation.isPending && selectedRequestId === request.id ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('proxy.advanced.replay.replay')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { Loader2, Copy, CheckCircle, ShieldAlert, XCircle, Terminal, Code2, Braces } from 'lucide-react';
|
||||
|
||||
const IDE_CONFIGS = [
|
||||
{ key: 'vscode', label: 'VS Code', icon: Code2 },
|
||||
{ key: 'cursor', label: 'Cursor', icon: Terminal },
|
||||
{ key: 'claude-code', label: 'Claude Code', icon: Braces },
|
||||
] as const;
|
||||
|
||||
export const ToolsTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: parityData, isLoading: parityLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'parityCounters'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getParityCounters(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const ideQueries = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['proxyAdvanced', 'ideConfig', 'vscode'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'vscode' }),
|
||||
},
|
||||
{
|
||||
queryKey: ['proxyAdvanced', 'ideConfig', 'cursor'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'cursor' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const parity = parityData?.success ? parityData.data : undefined;
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast({ title: t('proxy.advanced.tools.copyConfig') });
|
||||
};
|
||||
|
||||
const getIdeInstructions = (key: string): string => {
|
||||
if (key === 'claude-code') {
|
||||
return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY';
|
||||
}
|
||||
const index = key === 'vscode' ? 0 : 1;
|
||||
const result = ideQueries[index].data;
|
||||
if (result?.success && result.data) {
|
||||
const d = result.data;
|
||||
return `${d.instructions}\n${d.proxySetting}: http://localhost:8045\n${d.apiKeySetting}: YOUR_API_KEY`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const isIdeLoading = ideQueries.some((q) => q.isLoading);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Parity Counters */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('proxy.advanced.tools.totalRequests')}
|
||||
value={parity?.totalRequests ?? 0}
|
||||
icon={CheckCircle}
|
||||
isLoading={parityLoading}
|
||||
accent="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.tools.matchedRequests')}
|
||||
value={parity?.matchedRequests ?? 0}
|
||||
icon={ShieldAlert}
|
||||
isLoading={parityLoading}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.tools.parityViolations')}
|
||||
value={parity?.parityViolations ?? 0}
|
||||
icon={XCircle}
|
||||
isLoading={parityLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* IDE Quick Setup */}
|
||||
<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">
|
||||
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
|
||||
<p className="text-xs text-muted-foreground">Quick configuration for popular IDEs</p>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
{isIdeLoading ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
|
||||
const instructions = getIdeInstructions(key);
|
||||
return (
|
||||
<div key={key} className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-medium">{label}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => copyToClipboard(instructions)}
|
||||
>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('proxy.advanced.tools.copyConfig')}
|
||||
</Button>
|
||||
</div>
|
||||
{instructions && (
|
||||
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||
{instructions}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring/30',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
default: 'bg-accent/15 text-accent border border-accent/20',
|
||||
secondary: 'bg-secondary text-secondary-foreground border border-white/[0.06]',
|
||||
destructive: 'bg-destructive/15 text-red-400 border border-destructive/20',
|
||||
outline: 'border border-white/[0.1] text-muted-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -21,10 +19,11 @@ const badgeVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,24 +5,27 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-[13px] font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
default:
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
'bg-destructive/90 text-white hover:bg-destructive active:scale-[0.98] focus-visible:ring-destructive/30',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
'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',
|
||||
link: 'text-accent underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 text-xs',
|
||||
lg: 'h-10 rounded-lg px-6',
|
||||
icon: 'size-8 rounded-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -5,7 +5,10 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
|
||||
className={cn(
|
||||
'bg-card text-card-foreground rounded-xl border border-white/[0.06] shadow-sm transition-colors duration-200',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
@@ -23,7 +26,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('leading-none font-semibold tracking-tight', className)}
|
||||
className={cn('text-sm font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
@@ -34,7 +37,7 @@ const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||
<p ref={ref} className={cn('text-muted-foreground text-[13px]', className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Cross2Icon } from '@radix-ui/react-icons';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -19,7 +19,8 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -36,14 +37,18 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background 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%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
'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',
|
||||
'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%]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
@@ -70,7 +75,7 @@ const DialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
|
||||
className={cn('text-base leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -82,7 +87,7 @@ const DialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
className={cn('text-muted-foreground text-[13px]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
|
||||
'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]',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
@@ -39,7 +39,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] p-1 shadow-xl shadow-black/20',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -72,7 +72,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-lg border border-white/[0.08] p-1 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',
|
||||
className,
|
||||
)}
|
||||
@@ -92,7 +92,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'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',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
@@ -108,7 +108,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'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',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -131,7 +131,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -154,7 +154,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -166,7 +166,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('bg-muted -mx-1 my-1 h-px', className)}
|
||||
className={cn('-mx-1 my-1 h-px bg-white/[0.06]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'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',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:border-accent/50 focus-visible:ring-1 focus-visible:ring-accent/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
'text-[13px] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
|
||||
@@ -19,14 +19,18 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
'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',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-40" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
@@ -41,7 +45,7 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
@@ -55,7 +59,7 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
@@ -68,7 +72,9 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
|
||||
'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',
|
||||
'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' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
@@ -98,7 +104,7 @@ const SelectLabel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)}
|
||||
className={cn('py-1.5 pr-2 pl-8 text-xs font-medium text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -111,14 +117,16 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'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',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<Check className="h-3.5 w-3.5 text-accent" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
@@ -133,7 +141,7 @@ const SelectSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('bg-muted -mx-1 my-1 h-px', className)}
|
||||
className={cn('-mx-1 my-1 h-px bg-white/[0.06]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -1,26 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'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',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-white/[0.1]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200',
|
||||
'data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1',
|
||||
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1 border border-white/[0.04]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -26,7 +26,11 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm',
|
||||
'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',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -41,7 +45,7 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
'mt-3 animate-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -17,7 +17,10 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
'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',
|
||||
'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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, BarChart3, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface ChartStatesProps {
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
isEmpty: boolean;
|
||||
onRetry: () => void;
|
||||
title: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
errorMessage: string;
|
||||
retryLabel: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ChartCard: React.FC<ChartStatesProps> = ({
|
||||
isLoading,
|
||||
error,
|
||||
isEmpty,
|
||||
onRetry,
|
||||
title,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
errorMessage,
|
||||
retryLabel,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-white/[0.06] bg-card">
|
||||
<div className="flex flex-col p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold tracking-tight text-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex h-[280px] flex-col gap-3">
|
||||
<Skeleton className="h-full w-full rounded-lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/[0.04]">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-[13px] font-medium text-foreground">{emptyTitle}</p>
|
||||
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Premium chart palette — refined for dark mode contrast */
|
||||
export const COLOR_PROMPT = '#60a5fa'; // blue-400
|
||||
export const COLOR_COMPLETION = '#34d399'; // emerald-400
|
||||
export const COLOR_TOTAL = '#fbbf24'; // amber-400
|
||||
|
||||
export const PIE_COLORS = [
|
||||
'#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#f472b6',
|
||||
'#38bdf8', '#4ade80', '#facc15', '#c084fc',
|
||||
];
|
||||
|
||||
export const MODEL_COLORS: Record<string, string> = {
|
||||
'gemini-3-flash': '#60a5fa',
|
||||
'gemini-3.1-pro-low': '#818cf8',
|
||||
'gemini-3.1-pro-high': '#a78bfa',
|
||||
'claude-sonnet-4-6-thinking': '#f472b6',
|
||||
'claude-opus-4-6-thinking': '#fb7185',
|
||||
};
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
Prompt: COLOR_PROMPT,
|
||||
Completion: COLOR_COMPLETION,
|
||||
Total: COLOR_TOTAL,
|
||||
};
|
||||
|
||||
export function BarTooltip({ indexValue, data }: any) {
|
||||
const keys = Object.keys(data).filter((k: string) => k !== 'name' && colorMap[k]);
|
||||
|
||||
return (
|
||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||
<p className="text-foreground mb-1.5 text-xs font-medium">{indexValue}</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{keys.map((key: string) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: colorMap[key] }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{key}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PieTooltip({ datum }: any) {
|
||||
return (
|
||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: datum.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{datum.id}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineTooltip({ point }: any) {
|
||||
return (
|
||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||
<p className="text-foreground mb-1 text-xs font-medium">{point.data.xFormatted ?? point.data.x}</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: point.serieColor }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{point.serieId}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface TrendInfo {
|
||||
/** Percentage change vs previous period */
|
||||
value: number;
|
||||
direction: 'up' | 'down' | 'neutral';
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: LucideIcon;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
accent?: 'blue' | 'green' | 'amber' | 'slate' | 'purple';
|
||||
trend?: TrendInfo;
|
||||
sparkline?: number[];
|
||||
}
|
||||
|
||||
const ACCENT_ICON = {
|
||||
blue: 'text-blue-400',
|
||||
green: 'text-emerald-400',
|
||||
amber: 'text-amber-400',
|
||||
slate: 'text-muted-foreground',
|
||||
purple: 'text-violet-400',
|
||||
};
|
||||
|
||||
const ACCENT_BG = {
|
||||
blue: 'bg-blue-500/8',
|
||||
green: 'bg-emerald-500/8',
|
||||
amber: 'bg-amber-500/8',
|
||||
slate: 'bg-white/[0.04]',
|
||||
purple: 'bg-violet-500/8',
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a tiny inline SVG sparkline from an array of values.
|
||||
* Normalized to fit within a 60x20 viewBox.
|
||||
*/
|
||||
function Sparkline({ data, accent }: { data: number[]; accent: string }) {
|
||||
if (data.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1;
|
||||
const w = 60;
|
||||
const h = 20;
|
||||
const pad = 1;
|
||||
|
||||
const points = data.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * w;
|
||||
const y = h - pad - ((v - min) / range) * (h - pad * 2);
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const strokeColor =
|
||||
accent === 'blue'
|
||||
? '#60a5fa'
|
||||
: accent === 'green'
|
||||
? '#34d399'
|
||||
: accent === 'amber'
|
||||
? '#fbbf24'
|
||||
: accent === 'purple'
|
||||
? '#a78bfa'
|
||||
: '#6b7280';
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className="h-5 w-[60px] overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`sparkGrad-${accent}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Area fill */}
|
||||
<polygon
|
||||
points={`0,${h} ${points.join(' ')} ${w},${h}`}
|
||||
fill={`url(#sparkGrad-${accent})`}
|
||||
/>
|
||||
{/* Line */}
|
||||
<polyline
|
||||
points={points.join(' ')}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendBadge({ trend }: { trend: TrendInfo }) {
|
||||
const isUp = trend.direction === 'up';
|
||||
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
|
||||
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
|
||||
const color = isNeutral
|
||||
? 'text-muted-foreground'
|
||||
: isUp
|
||||
? 'text-emerald-400'
|
||||
: 'text-rose-400';
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
|
||||
<Icon className="h-3 w-3" />
|
||||
<span className="tabular-nums">{Math.abs(trend.value).toFixed(1)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const StatCard: React.FC<StatCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
isLoading,
|
||||
className,
|
||||
accent = 'slate',
|
||||
trend,
|
||||
sparkline,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative rounded-xl border border-white/[0.06] bg-card p-4 transition-all duration-200',
|
||||
'hover:border-white/[0.1] hover:bg-white/[0.02]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
||||
{label}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-7 w-20" />
|
||||
) : (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-foreground font-mono text-xl font-semibold tracking-tight tabular-nums">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</span>
|
||||
{trend && <TrendBadge trend={trend} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
ACCENT_BG[accent],
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', ACCENT_ICON[accent])} />
|
||||
</div>
|
||||
{sparkline && sparkline.length >= 2 && !isLoading && (
|
||||
<Sparkline data={sparkline} accent={accent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -151,7 +151,7 @@ export function useSetAccountProxy() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: (error: unknown) => {
|
||||
console.error('[Mutation] setAccountProxy failed:', error);
|
||||
},
|
||||
});
|
||||
@@ -176,7 +176,7 @@ export function useImportCloudAccounts() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: (error: unknown) => {
|
||||
console.error('[Mutation] importCloudAccounts failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -393,8 +393,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
await clearAccountStatus(account);
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
if (error.message === 'UNAUTHORIZED') {
|
||||
} catch (error: unknown) {
|
||||
if (extractErrorMessage(error) === 'UNAUTHORIZED') {
|
||||
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
@@ -462,7 +462,7 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
await markAccountStatusFromError(account, refreshError);
|
||||
throw refreshError;
|
||||
}
|
||||
} else if (error.message === 'FORBIDDEN') {
|
||||
} else if (extractErrorMessage(error) === 'FORBIDDEN') {
|
||||
logger.warn(
|
||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
||||
);
|
||||
@@ -574,9 +574,9 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
notifyTrayUpdate(account);
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
logger.error('Failed to switch cloud account', err);
|
||||
throw new Error(`Switch failed: ${err.message || 'Unknown error'}`);
|
||||
throw new Error(`Switch failed: ${extractErrorMessage(err) || 'Unknown error'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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: any) =>
|
||||
CloudMonitorService.poll().catch((err: unknown) =>
|
||||
logger.error('Failed to poll after enabling auto-switch', err),
|
||||
);
|
||||
}
|
||||
@@ -834,8 +834,8 @@ export async function importCloudAccounts(
|
||||
await CloudAccountRepo.addAccount(newAccount);
|
||||
result.imported++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to import ${acc.email}: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
result.errors.push(`Failed to import ${acc.email}: ${extractErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,8 +192,8 @@ export const cloudRouter = os.router({
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] setAccountProxy error:', error.message, error.stack);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
@@ -203,8 +203,8 @@ export const cloudRouter = os.router({
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw toSyncLocalAccountORPCError(error);
|
||||
}
|
||||
}),
|
||||
@@ -273,8 +273,8 @@ export const cloudRouter = os.router({
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await exportCloudAccounts(input.stripTokens);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', error.message, error.stack);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
@@ -297,8 +297,8 @@ export const cloudRouter = os.router({
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', error.message, error.stack);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -59,7 +59,7 @@ function sleepSync(ms: number): void {
|
||||
* Ensures that the cloud database file and schema exist.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
*/
|
||||
function ensureDatabaseInitialized(dbPath: string): void {
|
||||
export function ensureDatabaseInitialized(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -68,6 +68,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS });
|
||||
|
||||
// Create accounts table
|
||||
@@ -126,6 +127,44 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Create token_usage table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS token_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
timestamp INTEGER NOT NULL,
|
||||
request_type TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: Check if request_type column exists on token_usage
|
||||
const tokenUsageTableInfoRaw = db.pragma('table_info(token_usage)') as any[];
|
||||
const tokenUsageTableInfo = parseRows(
|
||||
TableInfoRowSchema,
|
||||
tokenUsageTableInfoRaw,
|
||||
'cloud.token_usage.tableInfo',
|
||||
);
|
||||
const hasRequestType = tokenUsageTableInfo.some((col) => col.name === 'request_type');
|
||||
if (!hasRequestType) {
|
||||
db.exec('ALTER TABLE token_usage ADD COLUMN request_type TEXT');
|
||||
}
|
||||
const hasIsEstimated = tokenUsageTableInfo.some((col) => col.name === 'is_estimated');
|
||||
if (!hasIsEstimated) {
|
||||
db.exec('ALTER TABLE token_usage ADD COLUMN is_estimated INTEGER');
|
||||
}
|
||||
|
||||
// Create indexes for token_usage
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_account ON token_usage(account_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_timestamp ON token_usage(timestamp);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_model ON token_usage(model);`);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_token_usage_account_timestamp ON token_usage(account_id, timestamp);`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize cloud database schema', error);
|
||||
throw error;
|
||||
@@ -137,7 +176,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
/**
|
||||
* Gets a connection to the cloud accounts database.
|
||||
*/
|
||||
function getCloudDb(): {
|
||||
export function getCloudDb(): {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof drizzleSchema>;
|
||||
} {
|
||||
@@ -522,9 +561,7 @@ export class CloudAccountRepo {
|
||||
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
|
||||
|
||||
const activeRows = rows.filter((r) => r.isActive);
|
||||
logger.debug(
|
||||
`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
||||
);
|
||||
logger.debug(`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`);
|
||||
|
||||
// Auto-activate the single account if none are active
|
||||
if (rows.length > 0 && activeRows.length === 0) {
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
|
||||
|
||||
export interface RecordProxyRequestParams {
|
||||
endpoint: string;
|
||||
statusCode: number;
|
||||
durationMs: number;
|
||||
timestamp: number;
|
||||
accountId?: string | null;
|
||||
tokensPrompt?: number | null;
|
||||
tokensCompletion?: number | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
let proxyMetricsDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getProxyMetricsDb() {
|
||||
if (!proxyMetricsDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
const conn = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
ensureProxyMetricsTable(conn.raw);
|
||||
proxyMetricsDbConnection = conn;
|
||||
}
|
||||
return proxyMetricsDbConnection;
|
||||
}
|
||||
|
||||
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
|
||||
try {
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
endpoint TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
account_id TEXT,
|
||||
tokens_prompt INTEGER,
|
||||
tokens_completion INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS traffic_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
method TEXT,
|
||||
status INTEGER,
|
||||
headers_json TEXT,
|
||||
body_json TEXT,
|
||||
chunk_json TEXT,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
error_stack TEXT,
|
||||
error_status INTEGER,
|
||||
metadata_json TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure proxy_request_metrics table', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyMetricsRepo {
|
||||
static recordRequest(params: RecordProxyRequestParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(proxyRequestMetrics)
|
||||
.values({
|
||||
endpoint: params.endpoint,
|
||||
statusCode: params.statusCode,
|
||||
durationMs: params.durationMs,
|
||||
timestamp: params.timestamp,
|
||||
accountId: params.accountId ?? null,
|
||||
tokensPrompt: params.tokensPrompt ?? null,
|
||||
tokensCompletion: params.tokensCompletion ?? null,
|
||||
errorMessage: params.errorMessage ?? null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record proxy request metric', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecentAggregates(): {
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60_000;
|
||||
const fiveMinutesAgo = now - 5 * 60_000;
|
||||
|
||||
try {
|
||||
const requestsPerMinuteRow = raw
|
||||
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
|
||||
.get(oneMinuteAgo) as { count: number } | undefined;
|
||||
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
|
||||
|
||||
const aggregatesRow = raw
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
AVG(duration_ms) as avgLatency,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics
|
||||
WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(fiveMinutesAgo) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
|
||||
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
|
||||
|
||||
return {
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent proxy metrics aggregates', error);
|
||||
return {
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static recordConnectionSnapshot(count: number): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record connection snapshot', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
avgActiveConnections: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowMs;
|
||||
|
||||
try {
|
||||
const reqRow = raw
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
||||
CASE WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(cutoff) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const totalRequests = reqRow?.total ?? 0;
|
||||
const minutes = Math.max(windowMs / 60_000, 1);
|
||||
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
||||
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
||||
const errorRatePercent = reqRow?.errorRate ?? 0;
|
||||
|
||||
const connRow = raw
|
||||
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
|
||||
.get(cutoff) as { avg: number | null } | undefined;
|
||||
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
avgActiveConnections,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get historical proxy metrics aggregates', error);
|
||||
return {
|
||||
totalRequests: 0,
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
avgActiveConnections: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOldRecords(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old proxy metrics records', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface InsertTrafficLogParams {
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class TrafficLogsRepo {
|
||||
static insert(params: InsertTrafficLogParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(trafficLogs)
|
||||
.values({
|
||||
timestamp: params.timestamp,
|
||||
direction: params.direction,
|
||||
requestId: params.requestId,
|
||||
endpoint: params.endpoint,
|
||||
method: params.method ?? null,
|
||||
status: params.status ?? null,
|
||||
headersJson: params.headers ? JSON.stringify(params.headers) : null,
|
||||
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
|
||||
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
|
||||
durationMs: params.durationMs ?? null,
|
||||
errorMessage: params.error?.message ?? null,
|
||||
errorStack: params.error?.stack ?? null,
|
||||
errorStatus: params.error?.status ?? null,
|
||||
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to insert traffic log', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecent(limit: number = 50, direction?: string): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
let sql = `SELECT * FROM traffic_logs`;
|
||||
const args: (number | string)[] = [];
|
||||
if (direction) {
|
||||
sql += ` WHERE direction = ?`;
|
||||
args.push(direction);
|
||||
}
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
||||
args.push(limit);
|
||||
const rows = raw.prepare(sql).all(...args) as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
|
||||
direction: string;
|
||||
endpoint: string;
|
||||
cnt: number;
|
||||
}>;
|
||||
const byDirection: Record<string, number> = {};
|
||||
const byEndpoint: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
total += r.cnt;
|
||||
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
|
||||
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
|
||||
}
|
||||
return { byDirection, byEndpoint, total };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { byDirection: {}, byEndpoint: {}, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
static getAllForExport(): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all traffic logs for export', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOld(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old traffic logs', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,51 @@ export const itemTable = sqliteTable('ItemTable', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value'),
|
||||
});
|
||||
|
||||
export const tokenUsage = sqliteTable('token_usage', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: text('account_id').notNull(),
|
||||
model: text('model').notNull(),
|
||||
promptTokens: integer('prompt_tokens').notNull().default(0),
|
||||
completionTokens: integer('completion_tokens').notNull().default(0),
|
||||
totalTokens: integer('total_tokens').notNull().default(0),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
requestType: text('request_type'),
|
||||
isEstimated: integer('is_estimated'),
|
||||
});
|
||||
|
||||
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
statusCode: integer('status_code').notNull(),
|
||||
durationMs: integer('duration_ms').notNull(),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
accountId: text('account_id'),
|
||||
tokensPrompt: integer('tokens_prompt'),
|
||||
tokensCompletion: integer('tokens_completion'),
|
||||
errorMessage: text('error_message'),
|
||||
});
|
||||
|
||||
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
count: integer('count').notNull().default(0),
|
||||
});
|
||||
|
||||
export const trafficLogs = sqliteTable('traffic_logs', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
direction: text('direction').notNull(),
|
||||
requestId: text('request_id').notNull(),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
method: text('method'),
|
||||
status: integer('status'),
|
||||
headersJson: text('headers_json'),
|
||||
bodyJson: text('body_json'),
|
||||
chunkJson: text('chunk_json'),
|
||||
durationMs: integer('duration_ms'),
|
||||
errorMessage: text('error_message'),
|
||||
errorStack: text('error_stack'),
|
||||
errorStatus: integer('error_status'),
|
||||
metadataJson: text('metadata_json'),
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { tokenUsage } from './schema';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
export interface RecordUsageParams {
|
||||
accountId: string;
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
timestamp: number;
|
||||
requestType?: string;
|
||||
isEstimated?: boolean;
|
||||
}
|
||||
|
||||
let usageDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getUsageDb() {
|
||||
if (!usageDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
usageDbConnection = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
return usageDbConnection;
|
||||
}
|
||||
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
|
||||
return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
|
||||
export class TokenUsageRepo {
|
||||
static recordUsage(params: RecordUsageParams): boolean {
|
||||
const { orm } = getUsageDb();
|
||||
try {
|
||||
orm
|
||||
.insert(tokenUsage)
|
||||
.values({
|
||||
accountId: params.accountId,
|
||||
model: params.model,
|
||||
promptTokens: params.promptTokens,
|
||||
completionTokens: params.completionTokens,
|
||||
totalTokens: params.totalTokens,
|
||||
timestamp: params.timestamp,
|
||||
requestType: params.requestType ?? null,
|
||||
isEstimated: params.isEstimated ? 1 : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record token usage', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByHour(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => HourlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get hourly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByDay(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => DailyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get daily usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByWeek(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => WeeklyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get weekly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByMonth(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => MonthlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get monthly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByModel(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
model,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY model
|
||||
ORDER BY totalTokens DESC
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => ModelUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,10 @@
|
||||
import { exec, execSync, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import findProcess, { ProcessInfo } from 'find-process';
|
||||
import { isNumber } from 'lodash-es';
|
||||
import { getGeminiNexusExecutablePath, isWsl } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const PROCESS_STARTUP_TIMEOUT_MS = 6000;
|
||||
const PROCESS_STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const LINUX_GPU_SAFE_LAUNCH_ARGS = ['--disable-gpu', '--disable-gpu-compositing'] as const;
|
||||
|
||||
/**
|
||||
* Helper process name patterns to exclude (Electron helper processes)
|
||||
*/
|
||||
const HELPER_PATTERNS = [
|
||||
'helper',
|
||||
'plugin',
|
||||
'renderer',
|
||||
'gpu',
|
||||
'crashpad',
|
||||
'utility',
|
||||
'audio',
|
||||
'sandbox',
|
||||
'language_server',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a process is a helper/auxiliary process that should be excluded.
|
||||
* @param name Process name (lowercase)
|
||||
* @param cmd Process command line (lowercase)
|
||||
* @returns True if the process is a helper process
|
||||
*/
|
||||
function isHelperProcess(name: string, cmd: string): boolean {
|
||||
const nameLower = name.toLowerCase();
|
||||
const cmdLower = cmd.toLowerCase();
|
||||
|
||||
// Check for --type= argument (Electron helper process indicator)
|
||||
if (cmdLower.includes('--type=')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for helper patterns in process name
|
||||
for (const pattern of HELPER_PATTERNS) {
|
||||
if (nameLower.includes(pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for crashpad in path
|
||||
if (cmdLower.includes('crashpad')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPgrepNoMatchError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
const hasPgrep = message.includes('pgrep') && message.includes('geminiNexus');
|
||||
const code = (error as { code?: number }).code;
|
||||
return hasPgrep && code === 1;
|
||||
}
|
||||
import {
|
||||
bootstrapNestServer,
|
||||
isNestServerRunning,
|
||||
stopNestServer,
|
||||
} from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
|
||||
/**
|
||||
* Checks if the Gemini Nexus process is running.
|
||||
@@ -72,122 +12,8 @@ function isPgrepNoMatchError(error: unknown): boolean {
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function isProcessRunning(): Promise<boolean> {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Use find-process to search for Gemini Nexus processes
|
||||
const allMatches: ProcessInfo[] = [];
|
||||
const searchNames = ['Gemini Nexus', 'geminiNexus'];
|
||||
if (platform === 'linux') {
|
||||
searchNames.push('electron');
|
||||
}
|
||||
let sawNoMatch = false;
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
try {
|
||||
const matches = await findProcess('name', searchName, false);
|
||||
allMatches.push(...matches);
|
||||
} catch (error) {
|
||||
if (isPgrepNoMatchError(error)) {
|
||||
sawNoMatch = true;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
for (const proc of allMatches) {
|
||||
if (isNumber(proc.pid)) {
|
||||
processMap.set(proc.pid, proc);
|
||||
}
|
||||
}
|
||||
|
||||
const processes = Array.from(processMap.values());
|
||||
if (processes.length === 0 && sawNoMatch) {
|
||||
logger.debug('No Gemini Nexus process found (pgrep returned 1)');
|
||||
}
|
||||
|
||||
logger.debug(`Found ${processes.length} processes matching 'GeminiNexus/geminiNexus'`);
|
||||
|
||||
for (const proc of processes) {
|
||||
// Skip self
|
||||
if (proc.pid === currentPid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = proc.name?.toLowerCase() || '';
|
||||
const cmd = proc.cmd?.toLowerCase() || '';
|
||||
|
||||
// Skip manager process
|
||||
if (name.includes('manager') || cmd.includes('manager') || cmd.includes('gemini-nexus')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip helper processes
|
||||
if (isHelperProcess(name, cmd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Check for Gemini Nexus.app in path
|
||||
if (cmd.includes('geminiNexus.app')) {
|
||||
logger.debug(
|
||||
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Also check if the process name is exactly 'Gemini Nexus' (main process)
|
||||
if (name === 'geminiNexus' && !isHelperProcess(name, cmd)) {
|
||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||
return true;
|
||||
}
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Check for Gemini Nexus.exe
|
||||
if (name === 'geminiNexus.exe' || name === 'geminiNexus') {
|
||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
const nameLower = name.toLowerCase();
|
||||
const cmdLower = cmd.toLowerCase();
|
||||
|
||||
if (nameLower === 'electron') {
|
||||
// Stricter check for AUR/Electron wrapper:
|
||||
// Must include geminiNexus in command line but NOT manager or tools
|
||||
const isGeminiNexusApp =
|
||||
(cmdLower.includes('/geminiNexus') ||
|
||||
cmdLower.includes(' geminiNexus') ||
|
||||
cmdLower.endsWith('geminiNexus')) &&
|
||||
!cmdLower.includes('manager') &&
|
||||
!cmdLower.includes('tools');
|
||||
|
||||
if (isGeminiNexusApp) {
|
||||
logger.debug(
|
||||
`Found Gemini Nexus (AUR/electron) process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(name.includes('geminiNexus') || cmd.includes('/geminiNexus')) &&
|
||||
!name.includes('tools')
|
||||
) {
|
||||
logger.debug(
|
||||
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('Error checking process status with find-process:', error);
|
||||
return false;
|
||||
}
|
||||
// Check the embedded NestJS proxy server status instead of an external process
|
||||
return isNestServerRunning();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,290 +21,26 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function closeGeminiNexus(): Promise<void> {
|
||||
logger.info('Closing Gemini Nexus...');
|
||||
const platform = process.platform;
|
||||
|
||||
try {
|
||||
// Stage 1: Graceful Shutdown (Platform specific)
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Use AppleScript to quit gracefully
|
||||
try {
|
||||
logger.info('Attempting graceful exit via AppleScript...');
|
||||
execSync('osascript -e \'tell application "Gemini Nexus" to quit\'', {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
// Wait for a moment
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
} catch {
|
||||
logger.warn('AppleScript exit failed, proceeding to next stage');
|
||||
}
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Use taskkill /IM (without /F) for graceful close
|
||||
try {
|
||||
logger.info('Attempting graceful exit via taskkill...');
|
||||
// /T = Tree (child processes), /IM = Image Name
|
||||
// We do not wait long here.
|
||||
execSync('taskkill /IM "GeminiNexus.exe" /T', {
|
||||
stdio: 'ignore',
|
||||
timeout: 2000,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} catch {
|
||||
// Ignore failure, we play hard next.
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2 & 3: Find and Kill remaining processes
|
||||
// We use a more aggressive approach here but try to avoid killing ourselves
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Helper to list processes
|
||||
const getProcesses = (): { pid: number; name: string; cmd: string }[] => {
|
||||
try {
|
||||
let output = '';
|
||||
if (platform === 'win32') {
|
||||
const psCommand = (cmdlet: string) =>
|
||||
`powershell -NoProfile -Command "${cmdlet} Win32_Process -Filter \\"Name like 'GeminiNexus%'\\" | Select-Object ProcessId, Name, CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
|
||||
try {
|
||||
output = execSync(psCommand('Get-CimInstance'), {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
} catch (e) {
|
||||
// CIM failed (likely older OS), try WMI
|
||||
try {
|
||||
output = execSync(psCommand('Get-WmiObject'), {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
});
|
||||
} catch (innerE) {
|
||||
// Both failed, throw original or log? Throwing lets the outer catch handle it (returning empty list)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unix/Linux/macOS
|
||||
output = execSync('ps -A -o pid,comm,args', {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
});
|
||||
}
|
||||
|
||||
const processList: { pid: number; name: string; cmd: string }[] = [];
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Parse CSV Output
|
||||
const lines = output.trim().split(/\r?\n/);
|
||||
// First line is headers "ProcessId","Name","CommandLine"
|
||||
// We start from index 1
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regex to match CSV fields: "val1","val2","val3"
|
||||
const match = line.match(/^"(\d+)","(.*?)","(.*?)"$/);
|
||||
|
||||
if (match) {
|
||||
const pid = parseInt(match[1]);
|
||||
const name = match[2];
|
||||
const cmdLine = match[3];
|
||||
|
||||
processList.push({ pid, name, cmd: cmdLine || name });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const pid = parseInt(parts[0]);
|
||||
if (isNaN(pid)) continue;
|
||||
const rest = parts.slice(1).join(' ');
|
||||
if (rest.includes('Gemini Nexus') || rest.includes('geminiNexus')) {
|
||||
processList.push({ pid, name: parts[1], cmd: rest });
|
||||
}
|
||||
}
|
||||
}
|
||||
return processList;
|
||||
} catch (e) {
|
||||
logger.error('Failed to list processes', e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const targetProcessList = getProcesses().filter((p) => {
|
||||
// Exclude self
|
||||
if (p.pid === currentPid) {
|
||||
return false;
|
||||
}
|
||||
// Exclude this electron app (if named Gemini Nexus or gemini-nexus)
|
||||
if (p.cmd.includes('Gemini Nexus') || p.cmd.includes('gemini-nexus')) {
|
||||
return false;
|
||||
}
|
||||
// Match Gemini Nexus (but not manager)
|
||||
if (platform === 'win32') {
|
||||
return (
|
||||
p.cmd.includes('GeminiNexus.exe') ||
|
||||
(p.cmd.includes('geminiNexus') && !p.cmd.includes('manager'))
|
||||
);
|
||||
} else {
|
||||
// Explicit !manager check for Linux/macOS to be defensive
|
||||
return (
|
||||
(p.cmd.includes('Gemini Nexus') || p.cmd.includes('geminiNexus')) &&
|
||||
!p.cmd.includes('manager')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (targetProcessList.length === 0) {
|
||||
logger.info('No Gemini Nexus processes found running.');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${targetProcessList.length} remaining Gemini Nexus processes. Killing...`);
|
||||
|
||||
for (const p of targetProcessList) {
|
||||
try {
|
||||
process.kill(p.pid, 'SIGKILL'); // Force kill as final step
|
||||
} catch {
|
||||
// Ignore if already dead
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error closing Gemini Nexus', error);
|
||||
// Fallback to simple kill if everything fails
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
execSync('taskkill /F /IM "GeminiNexus.exe" /T', { stdio: 'ignore' });
|
||||
} else {
|
||||
execSync('pkill -9 -f Gemini Nexus', { stdio: 'ignore' });
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
logger.info('Stopping Gemini Nexus proxy server...');
|
||||
await stopNestServer();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Waits for the Gemini Nexus process to exit.
|
||||
* @param timeoutMs {number} The timeout in milliseconds.
|
||||
* Polls the NestJS server status until it stops or the timeout is reached.
|
||||
* @param timeoutMs {number} Maximum time to wait in milliseconds.
|
||||
* @returns {Promise<void>} A promise that resolves when the process exits.
|
||||
*/
|
||||
export async function _waitForProcessExit(
|
||||
timeoutMs: number,
|
||||
pollInterval = 100, // Make it configurable, but keep fast 100ms default
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (!(await isProcessRunning())) {
|
||||
return;
|
||||
export async function _waitForProcessExit(timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (isNestServerRunning()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error('Timeout waiting for Gemini Nexus proxy server to stop');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Gemini Nexus process did not exit within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a URI protocol.
|
||||
* @param uri {string} The URI to open.
|
||||
* @returns {Promise<boolean>} True if the URI was opened successfully, false otherwise.
|
||||
*/
|
||||
async function openUri(uri: string): Promise<boolean> {
|
||||
const platform = process.platform;
|
||||
const wsl = isWsl();
|
||||
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
// macOS: use open command
|
||||
await execAsync(`open "${uri}"`);
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: use start command
|
||||
await execAsync(`start "" "${uri}"`);
|
||||
} else if (wsl) {
|
||||
// WSL: use cmd.exe to open URI
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${uri}"`);
|
||||
} else {
|
||||
// Linux: use xdg-open
|
||||
await execAsync(`xdg-open "${uri}"`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to open URI', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForGeminiNexusStartup(
|
||||
timeoutMs = PROCESS_STARTUP_TIMEOUT_MS,
|
||||
pollIntervalMs = PROCESS_STARTUP_POLL_INTERVAL_MS,
|
||||
): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isProcessRunning()) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldUseLinuxGpuSafeLaunchArgs(): boolean {
|
||||
if (process.platform !== 'linux' || isWsl()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enableLinuxGpuRaw = process.env.ANTIGRAVITY_MANAGER_ENABLE_LINUX_GPU?.trim().toLowerCase();
|
||||
return enableLinuxGpuRaw !== '1' && enableLinuxGpuRaw !== 'true';
|
||||
}
|
||||
|
||||
async function startGeminiNexusByExecutable(execPath: string): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
await execAsync(`open -a Gemini Nexus`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
// Use start command to detach
|
||||
await execAsync(`start "" "${execPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWsl()) {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
// In WSL, convert path and use cmd.exe
|
||||
const winPath = execPath
|
||||
.replace(/^\/mnt\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`)
|
||||
.replace(/\//g, '\\');
|
||||
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${winPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
|
||||
const launchArgs = shouldUseLinuxGpuSafeLaunchArgs() ? [...LINUX_GPU_SAFE_LAUNCH_ARGS] : [];
|
||||
if (launchArgs.length > 0) {
|
||||
logger.info(`Linux launch with GPU-safe args: ${launchArgs.join(' ')}`);
|
||||
}
|
||||
|
||||
const child = spawn(execPath, launchArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -486,56 +48,18 @@ async function startGeminiNexusByExecutable(execPath: string): Promise<void> {
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus.
|
||||
* @returns {Promise<void>} A promise that resolves when the process starts.
|
||||
*/
|
||||
export async function startGeminiNexus(useUri = true): Promise<void> {
|
||||
logger.info('Starting Gemini Nexus...');
|
||||
export async function startGeminiNexus(_useUri = true): Promise<void> {
|
||||
logger.info('Starting Gemini Nexus proxy server...');
|
||||
|
||||
if (await isProcessRunning()) {
|
||||
logger.info('Gemini Nexus is already running');
|
||||
if (isNestServerRunning()) {
|
||||
logger.info('Gemini Nexus proxy server is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useUri) {
|
||||
logger.info('Using URI protocol to start...');
|
||||
const uri = 'geminiNexus://oauth-success';
|
||||
|
||||
if (await openUri(uri)) {
|
||||
logger.info('Gemini Nexus URI launch command sent');
|
||||
|
||||
if (process.platform !== 'linux' || isWsl()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await waitForGeminiNexusStartup()) {
|
||||
logger.info('Gemini Nexus process detected after URI launch');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
'URI launch did not keep Gemini Nexus running on Linux. Falling back to executable launch.',
|
||||
);
|
||||
} else {
|
||||
logger.warn('URI launch failed, trying executable path...');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to executable path
|
||||
logger.info('Using executable path to start...');
|
||||
const execPath = getGeminiNexusExecutablePath();
|
||||
|
||||
try {
|
||||
await startGeminiNexusByExecutable(execPath);
|
||||
logger.info('Gemini Nexus launch command sent');
|
||||
|
||||
if (process.platform === 'linux' && !isWsl()) {
|
||||
const started = await waitForGeminiNexusStartup();
|
||||
if (!started) {
|
||||
logger.warn(
|
||||
'Gemini Nexus launch command completed, but process startup could not be confirmed on Linux.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Gemini Nexus via executable', error);
|
||||
throw error;
|
||||
const config = ConfigManager.loadConfig();
|
||||
if (config.proxy) {
|
||||
await bootstrapNestServer(config.proxy);
|
||||
} else {
|
||||
throw new Error('Proxy configuration not found');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getServiceOrThrow } from './service-registry';
|
||||
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
|
||||
export interface TrafficLogEntryParsed {
|
||||
timestamp: string;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeServiceError(error: unknown): string {
|
||||
if (error instanceof Error && error.message.includes('is not registered')) {
|
||||
return 'Proxy advanced service not initialized';
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function getRecentTrafficLogs(
|
||||
limit: number = 50,
|
||||
direction?: string,
|
||||
): Promise<{ success: boolean; data?: TrafficLogEntryParsed[]; error?: string }> {
|
||||
try {
|
||||
const rows = TrafficLogsRepo.getRecent(limit, direction);
|
||||
const entries: TrafficLogEntryParsed[] = rows.map((r) => ({
|
||||
timestamp: new Date(r.timestamp).toISOString(),
|
||||
direction: r.direction,
|
||||
requestId: r.requestId,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method,
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
body: r.body,
|
||||
chunk: r.chunk,
|
||||
durationMs: r.durationMs,
|
||||
error: r.error,
|
||||
metadata: r.metadata,
|
||||
}));
|
||||
|
||||
return { success: true, data: entries };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrafficLogStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const stats = TrafficLogsRepo.getStats();
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportTrafficLogs(
|
||||
format: 'json' | 'csv',
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
try {
|
||||
const rows = TrafficLogsRepo.getAllForExport();
|
||||
|
||||
if (format === 'json') {
|
||||
return { success: true, data: JSON.stringify(rows, null, 2) };
|
||||
}
|
||||
|
||||
// CSV format
|
||||
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
|
||||
const rowsCsv = rows.map((e) =>
|
||||
[
|
||||
new Date(e.timestamp).toISOString(),
|
||||
e.direction,
|
||||
e.requestId,
|
||||
e.endpoint,
|
||||
e.method || '',
|
||||
e.status != null ? e.status : '',
|
||||
e.durationMs != null ? e.durationMs : '',
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(','),
|
||||
);
|
||||
|
||||
return { success: true, data: [headers.join(','), ...rowsCsv].join('\n') };
|
||||
} catch (error) {
|
||||
logger.error('Failed to export traffic logs', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getParityCounters(): Promise<{
|
||||
success: boolean;
|
||||
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
return {
|
||||
success: true,
|
||||
data: tokenManager.getParityCounters(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCircuitBreakerStatus(): Promise<{
|
||||
success: boolean;
|
||||
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
|
||||
const rawStatus = tokenManager.getCircuitBreakerStatus();
|
||||
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
|
||||
for (const [accountId, entry] of Object.entries(rawStatus)) {
|
||||
states[accountId] = {
|
||||
state: entry.state,
|
||||
failures: entry.failureCount,
|
||||
lastFailure: entry.lastFailureTime ? new Date(entry.lastFailureTime).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
states,
|
||||
globalEnabled: true,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProxyMetrics(): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
uptimeSeconds: number;
|
||||
totalRequests: number;
|
||||
activeConnections: number;
|
||||
avgLatency: number;
|
||||
errorRate: number;
|
||||
requestsPerMinute: number;
|
||||
cacheHitRate: number;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyMetrics = getServiceOrThrow('proxyMetrics');
|
||||
|
||||
const metrics = proxyMetrics.getMetrics();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
totalRequests: metrics.totalRequests,
|
||||
activeConnections: metrics.activeConnections,
|
||||
avgLatency: metrics.avgLatency,
|
||||
errorRate: metrics.errorRate,
|
||||
requestsPerMinute: metrics.requestsPerMinute,
|
||||
cacheHitRate: metrics.cacheHitRate,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get proxy metrics', error);
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
return {
|
||||
success: true,
|
||||
data: proxyReplay.getRecentRequests(20),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayRequest(
|
||||
requestId: string,
|
||||
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
const result = await proxyReplay.replayRequest(requestId);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelCapabilityEntry {
|
||||
id: string;
|
||||
object: string;
|
||||
displayName?: string;
|
||||
capabilities: {
|
||||
vision: boolean;
|
||||
thinking: boolean;
|
||||
streaming: boolean;
|
||||
jsonMode: boolean;
|
||||
recommended: boolean;
|
||||
};
|
||||
limits: {
|
||||
maxTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
thinkingBudget?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(): Promise<{
|
||||
success: boolean;
|
||||
data?: ModelCapabilityEntry[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
|
||||
const modelMap = new Map<string, ModelCapabilityEntry>();
|
||||
|
||||
for (const account of accounts) {
|
||||
if (!account.quota || !account.quota.models) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [modelId, modelInfo] of Object.entries(account.quota.models)) {
|
||||
if (modelMap.has(modelId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
modelMap.set(modelId, {
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
displayName: modelInfo.display_name,
|
||||
capabilities: {
|
||||
vision: modelInfo.supports_images ?? false,
|
||||
thinking: modelInfo.supports_thinking ?? false,
|
||||
streaming: true,
|
||||
jsonMode: true,
|
||||
recommended: modelInfo.recommended ?? false,
|
||||
},
|
||||
limits: {
|
||||
maxTokens: modelInfo.max_tokens,
|
||||
maxOutputTokens: modelInfo.max_output_tokens,
|
||||
thinkingBudget: modelInfo.thinking_budget,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const data = Array.from(modelMap.values()).sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model capabilities', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function mapIdeConfigResult(
|
||||
result: IdeConfigResult,
|
||||
): {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
} {
|
||||
switch (result.ide) {
|
||||
case 'vscode':
|
||||
return {
|
||||
name: 'VS Code',
|
||||
settingsPath: 'settings.json',
|
||||
proxySetting: 'openai.url',
|
||||
apiKeySetting: 'openai.apiKey',
|
||||
instructions: 'Add these settings to your VS Code settings.json',
|
||||
};
|
||||
case 'cursor':
|
||||
return {
|
||||
name: 'Cursor',
|
||||
settingsPath: 'settings.json',
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Set these environment variables for Cursor',
|
||||
};
|
||||
case 'claude-code':
|
||||
return {
|
||||
name: 'Claude Code',
|
||||
settingsPath: 'Shell environment',
|
||||
proxySetting: 'ANTHROPIC_BASE_URL',
|
||||
apiKeySetting: 'ANTHROPIC_API_KEY',
|
||||
instructions: 'Run these export commands before starting Claude Code',
|
||||
};
|
||||
case 'jetbrains':
|
||||
return {
|
||||
name: 'JetBrains',
|
||||
settingsPath: 'IDE Settings',
|
||||
proxySetting: 'Base URL',
|
||||
apiKeySetting: 'API Key',
|
||||
instructions: String(result.content),
|
||||
};
|
||||
case 'opencide':
|
||||
return {
|
||||
name: 'Opencide',
|
||||
settingsPath: '.env',
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Add these lines to your .env file',
|
||||
};
|
||||
default: {
|
||||
const exhaustiveCheck: never = result.ide;
|
||||
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateIdeConfig(
|
||||
ide: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
|
||||
|
||||
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
|
||||
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
|
||||
if (!supportedIdes.includes(normalizedIde)) {
|
||||
return { success: false, error: `Unsupported IDE: ${ide}` };
|
||||
}
|
||||
|
||||
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
|
||||
if (!result) {
|
||||
return { success: false, error: 'Failed to generate IDE config: server not configured' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: mapIdeConfigResult(result),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import {
|
||||
getRecentTrafficLogs,
|
||||
getTrafficLogStats,
|
||||
exportTrafficLogs,
|
||||
getParityCounters,
|
||||
getCircuitBreakerStatus,
|
||||
getProxyMetrics,
|
||||
getRecentRequests,
|
||||
generateIdeConfig,
|
||||
replayRequest,
|
||||
getModelCapabilities,
|
||||
} from './handler';
|
||||
|
||||
const TrafficLogEntrySchema = z.object({
|
||||
timestamp: z.string(),
|
||||
direction: z.string(),
|
||||
requestId: z.string(),
|
||||
endpoint: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
headers: z.record(z.string(), z.unknown()).optional(),
|
||||
body: z.unknown().optional(),
|
||||
chunk: z.unknown().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
error: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
stack: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
body: z.unknown().optional(),
|
||||
})
|
||||
.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const TrafficLogsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(TrafficLogEntrySchema).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const TrafficLogStatsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
byDirection: z.record(z.string(), z.number()),
|
||||
byEndpoint: z.record(z.string(), z.number()),
|
||||
total: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ExportTrafficLogsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ParityCountersResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
totalRequests: z.number(),
|
||||
matchedRequests: z.number(),
|
||||
parityViolations: z.number(),
|
||||
lastUpdated: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const CircuitBreakerStatusResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
states: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
state: z.string(),
|
||||
failures: z.number(),
|
||||
lastFailure: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
globalEnabled: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ProxyMetricsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
uptimeSeconds: z.number(),
|
||||
totalRequests: z.number(),
|
||||
activeConnections: z.number(),
|
||||
avgLatency: z.number(),
|
||||
errorRate: z.number(),
|
||||
requestsPerMinute: z.number(),
|
||||
cacheHitRate: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const RecentRequestsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(z.unknown()).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ReplayRequestResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
original: z.unknown(),
|
||||
newResponse: z.unknown(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ModelCapabilitiesResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
object: z.string(),
|
||||
displayName: z.string().optional(),
|
||||
capabilities: z.object({
|
||||
vision: z.boolean(),
|
||||
thinking: z.boolean(),
|
||||
streaming: z.boolean(),
|
||||
jsonMode: z.boolean(),
|
||||
recommended: z.boolean(),
|
||||
}),
|
||||
limits: z.object({
|
||||
maxTokens: z.number().optional(),
|
||||
maxOutputTokens: z.number().optional(),
|
||||
thinkingBudget: z.number().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const IdeConfigResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
settingsPath: z.string(),
|
||||
proxySetting: z.string(),
|
||||
apiKeySetting: z.string(),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const proxyAdvancedRouter = os.router({
|
||||
getRecentTrafficLogs: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().int().min(1).max(1000).optional(),
|
||||
direction: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.output(TrafficLogsResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return getRecentTrafficLogs(input.limit, input.direction);
|
||||
}),
|
||||
|
||||
getTrafficLogStats: os.output(TrafficLogStatsResponseSchema).handler(async () => {
|
||||
return getTrafficLogStats();
|
||||
}),
|
||||
|
||||
exportTrafficLogs: os
|
||||
.input(z.object({ format: z.enum(['json', 'csv']) }))
|
||||
.output(ExportTrafficLogsResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return exportTrafficLogs(input.format);
|
||||
}),
|
||||
|
||||
getParityCounters: os.output(ParityCountersResponseSchema).handler(async () => {
|
||||
return getParityCounters();
|
||||
}),
|
||||
|
||||
getCircuitBreakerStatus: os.output(CircuitBreakerStatusResponseSchema).handler(async () => {
|
||||
return getCircuitBreakerStatus();
|
||||
}),
|
||||
|
||||
getProxyMetrics: os.output(ProxyMetricsResponseSchema).handler(async () => {
|
||||
return getProxyMetrics();
|
||||
}),
|
||||
|
||||
getRecentRequests: os.output(RecentRequestsResponseSchema).handler(async () => {
|
||||
return getRecentRequests();
|
||||
}),
|
||||
|
||||
generateIdeConfig: os
|
||||
.input(z.object({ ide: z.string() }))
|
||||
.output(IdeConfigResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return generateIdeConfig(input.ide);
|
||||
}),
|
||||
|
||||
replayRequest: os
|
||||
.input(z.object({ requestId: z.string() }))
|
||||
.output(ReplayRequestResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return replayRequest(input.requestId);
|
||||
}),
|
||||
|
||||
getModelCapabilities: os.output(ModelCapabilitiesResponseSchema).handler(async () => {
|
||||
return getModelCapabilities();
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Service registry for proxy-advanced IPC handlers to access
|
||||
* NestJS service instances without DI coupling.
|
||||
*
|
||||
* Services auto-register on construction.
|
||||
*/
|
||||
import type { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
||||
import type { ProxyMetricsService } from '../../server/modules/proxy/proxy-metrics.service';
|
||||
import type { ProxyReplayService } from '../../server/modules/proxy/proxy-replay.service';
|
||||
import type { ProxyIdeConfigService } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
|
||||
interface ProxyAdvancedRegistry {
|
||||
tokenManager: TokenManagerService | null;
|
||||
proxyMetrics: ProxyMetricsService | null;
|
||||
proxyReplay: ProxyReplayService | null;
|
||||
proxyIdeConfig: ProxyIdeConfigService | null;
|
||||
}
|
||||
|
||||
export const proxyAdvancedRegistry: ProxyAdvancedRegistry = {
|
||||
tokenManager: null,
|
||||
proxyMetrics: null,
|
||||
proxyReplay: null,
|
||||
proxyIdeConfig: null,
|
||||
};
|
||||
|
||||
export function registerProxyAdvancedService<K extends keyof ProxyAdvancedRegistry>(
|
||||
key: K,
|
||||
instance: ProxyAdvancedRegistry[K],
|
||||
): void {
|
||||
proxyAdvancedRegistry[key] = instance;
|
||||
}
|
||||
|
||||
export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
|
||||
key: K,
|
||||
): NonNullable<ProxyAdvancedRegistry[K]> {
|
||||
const svc = proxyAdvancedRegistry[key];
|
||||
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
|
||||
return svc;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { accountRouter } from './account/router';
|
||||
import { cloudRouter } from './cloud/router';
|
||||
import { configRouter } from './config/router';
|
||||
import { gatewayRouter } from './gateway/router';
|
||||
import { usageRouter } from './usage/router';
|
||||
import { proxyAdvancedRouter } from './proxy-advanced/router';
|
||||
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
@@ -53,5 +55,7 @@ export const router = os.use(logMiddleware).router({
|
||||
cloud: cloudRouter,
|
||||
config: configRouter,
|
||||
gateway: gatewayRouter,
|
||||
usage: usageRouter,
|
||||
proxyAdvanced: proxyAdvancedRouter,
|
||||
system: systemHandler,
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { TokenUsageRepo } from '../database/usageHandler';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
const RecordUsageInputSchema = z.object({
|
||||
accountId: z.string(),
|
||||
model: z.string(),
|
||||
promptTokens: z.number().int().min(0),
|
||||
completionTokens: z.number().int().min(0),
|
||||
totalTokens: z.number().int().min(0),
|
||||
timestamp: z.number().int(),
|
||||
requestType: z.string().optional(),
|
||||
});
|
||||
|
||||
const UsageQueryInputSchema = z.object({
|
||||
accountId: z.string().optional(),
|
||||
start: z.number().int().optional(),
|
||||
end: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export const usageRouter = os.router({
|
||||
recordUsage: os
|
||||
.input(RecordUsageInputSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
TokenUsageRepo.recordUsage(input);
|
||||
}),
|
||||
|
||||
getUsageByHour: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(HourlyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByHour(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByDay: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(DailyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByDay(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByWeek: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(WeeklyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByWeek(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByMonth: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(MonthlyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByMonth(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByModel: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(ModelUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByModel(input.accountId, input.start, input.end);
|
||||
}),
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -16,6 +17,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
import iconPng from '@/assets/icon.png';
|
||||
|
||||
export const MainLayout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
@@ -40,6 +42,11 @@ export const MainLayout: React.FC = () => {
|
||||
icon: LayoutDashboard,
|
||||
label: t('nav.accounts'),
|
||||
},
|
||||
{
|
||||
to: '/usage',
|
||||
icon: BarChart3,
|
||||
label: t('nav.usage'),
|
||||
},
|
||||
{
|
||||
to: '/proxy',
|
||||
icon: Network,
|
||||
@@ -58,14 +65,21 @@ export const MainLayout: React.FC = () => {
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'bg-muted/10 group relative flex flex-col border-r transition-all duration-300 ease-in-out',
|
||||
isCollapsed ? 'w-[70px]' : 'w-64',
|
||||
'relative flex flex-col border-r border-white/[0.06] transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
isCollapsed ? 'w-[60px]' : 'w-56',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-background hover:bg-accent hover:text-accent-foreground absolute top-6 -right-3 z-10 h-6 w-6 rounded-full border opacity-0 shadow-md transition-opacity group-hover:opacity-100"
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
className={cn(
|
||||
'absolute -right-3 top-7 z-10 flex h-6 w-6 items-center justify-center rounded-full',
|
||||
'bg-muted border border-white/[0.1] text-muted-foreground',
|
||||
'opacity-0 transition-all duration-200 hover:bg-white/[0.1] hover:text-foreground',
|
||||
'group-hover:opacity-100',
|
||||
)}
|
||||
style={{ opacity: 0 }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0')}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
@@ -73,13 +87,14 @@ export const MainLayout: React.FC = () => {
|
||||
) : (
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-4' : 'p-6')}>
|
||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-transparent">
|
||||
{/* Brand */}
|
||||
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-3' : 'px-4 py-5')}>
|
||||
<div className="flex items-center gap-2.5 overflow-hidden whitespace-nowrap">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
|
||||
<img
|
||||
src="/src/assets/icon.png"
|
||||
src={iconPng}
|
||||
alt="Gemini Nexus Logo"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
@@ -90,12 +105,13 @@ export const MainLayout: React.FC = () => {
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
<h1 className="text-xl font-bold tracking-tight">Gemini Nexus</h1>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Gemini Nexus</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2 px-2">
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-2 pt-2">
|
||||
<TooltipProvider>
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.to;
|
||||
@@ -107,17 +123,19 @@ export const MainLayout: React.FC = () => {
|
||||
<Link
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'mx-auto flex h-10 w-10 items-center justify-center rounded-md transition-colors',
|
||||
'mx-auto flex h-9 w-9 items-center justify-center rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
||||
? 'bg-white/[0.08] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<item.icon className="h-[18px] w-[18px]" />
|
||||
<span className="sr-only">{item.label}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -127,13 +145,13 @@ export const MainLayout: React.FC = () => {
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] font-medium transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
||||
? 'bg-white/[0.08] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<item.icon className="h-[18px] w-[18px]" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
@@ -141,7 +159,8 @@ export const MainLayout: React.FC = () => {
|
||||
</TooltipProvider>
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-2">
|
||||
{/* Status bar */}
|
||||
<div className="border-t border-white/[0.06] p-2">
|
||||
<StatusBar isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</aside>
|
||||
@@ -167,9 +186,9 @@ export const MainLayout: React.FC = () => {
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<div className="text-lg font-semibold">{t('error.generic')}</div>
|
||||
<div className="text-muted-foreground mt-2 text-sm">{t('action.retry')}</div>
|
||||
<div className="rounded-xl border border-white/[0.06] border-dashed p-8 text-center">
|
||||
<div className="text-sm font-semibold">{t('error.generic')}</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
|
||||
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('action.retry')}
|
||||
|
||||
@@ -694,7 +694,13 @@ function buildGenerationConfig(
|
||||
if (maxTokens !== undefined) {
|
||||
config.maxOutputTokens = maxTokens;
|
||||
}
|
||||
const defaultStopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
|
||||
const defaultStopSequences = [
|
||||
'<|user|>',
|
||||
'<|endoftext|>',
|
||||
'<|end_of_turn|>',
|
||||
'[DONE]',
|
||||
'\n\nHuman:',
|
||||
];
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])];
|
||||
} else {
|
||||
@@ -705,7 +711,10 @@ function buildGenerationConfig(
|
||||
if (claudeReq.response_format) {
|
||||
if (claudeReq.response_format.type === 'json_object') {
|
||||
config.responseMimeType = 'application/json';
|
||||
} else if (claudeReq.response_format.type === 'json_schema' && claudeReq.response_format.json_schema) {
|
||||
} else if (
|
||||
claudeReq.response_format.type === 'json_schema' &&
|
||||
claudeReq.response_format.json_schema
|
||||
) {
|
||||
config.responseMimeType = 'application/json';
|
||||
config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema);
|
||||
}
|
||||
|
||||
@@ -254,7 +254,10 @@ class NonStreamingProcessor {
|
||||
/**
|
||||
* Public API: Transform Gemini Response to Claude Response
|
||||
*/
|
||||
export function transformResponse(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse {
|
||||
export function transformResponse(
|
||||
geminiResponse: GeminiResponse,
|
||||
modelName?: string,
|
||||
): ClaudeResponse {
|
||||
const processor = new NonStreamingProcessor();
|
||||
return processor.process(geminiResponse, modelName);
|
||||
}
|
||||
@@ -61,8 +61,8 @@ export class QuotaService {
|
||||
} else {
|
||||
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
return [undefined, undefined];
|
||||
@@ -138,7 +138,7 @@ export class QuotaService {
|
||||
}
|
||||
|
||||
return { quotaData, projectId };
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
let shouldFallback = true;
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -174,7 +174,7 @@ export class QuotaService {
|
||||
lastError = new Error(`HTTP ${status} - ${text}`);
|
||||
shouldFallback = !isNumber(status);
|
||||
} else {
|
||||
logger.warn(`Request Failed at ${endpoint}: ${error.message}`);
|
||||
logger.warn(`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ 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.message}`);
|
||||
throw lastError ?? new Error(`Quota query failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { PartialTheme } from '@nivo/theming';
|
||||
|
||||
/**
|
||||
* Nivo chart theme aligned with Gemini Nexus design system.
|
||||
* Uses Inter font family and refined dark palette.
|
||||
*/
|
||||
export const nivoTheme: PartialTheme = {
|
||||
background: 'transparent',
|
||||
text: {
|
||||
fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
fontSize: 11,
|
||||
fill: '#6b7280',
|
||||
},
|
||||
axis: {
|
||||
domain: {
|
||||
line: {
|
||||
stroke: 'rgba(255,255,255,0.06)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
line: {
|
||||
stroke: 'transparent',
|
||||
strokeWidth: 0,
|
||||
},
|
||||
text: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: 10,
|
||||
fill: '#6b7280',
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
text: {
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontSize: 11,
|
||||
fill: '#6b7280',
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
line: {
|
||||
stroke: 'rgba(255,255,255,0.04)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
},
|
||||
legends: {
|
||||
text: {
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontSize: 11,
|
||||
fill: '#6b7280',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
container: {
|
||||
background: '#16161e',
|
||||
color: '#e8eaed',
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontSize: 12,
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
boxShadow: '0 8px 24px -4px rgba(0, 0, 0, 0.3)',
|
||||
padding: '8px 12px',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -58,6 +58,7 @@ i18n
|
||||
},
|
||||
nav: {
|
||||
accounts: 'Accounts',
|
||||
usage: 'Usage',
|
||||
proxy: 'API Proxy',
|
||||
settings: 'Settings',
|
||||
},
|
||||
@@ -488,10 +489,138 @@ i18n
|
||||
maps_to: 'Maps to',
|
||||
restore: 'Restore Defaults',
|
||||
},
|
||||
routing: {
|
||||
currentMappings: 'Current Model Mappings',
|
||||
modelMapping: 'Anthropic model routing configuration',
|
||||
noMapping: 'No model mappings configured.',
|
||||
upstreamProxy: 'Outgoing proxy configuration',
|
||||
status: 'Status',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
url: 'URL',
|
||||
},
|
||||
examples: {
|
||||
title: 'Usage Examples',
|
||||
description: 'Example commands to call the local API proxy.',
|
||||
},
|
||||
advanced: {
|
||||
title: 'Advanced',
|
||||
monitor: 'Monitor',
|
||||
metrics: {
|
||||
title: 'Performance Metrics',
|
||||
requestsPerMinute: 'Requests/min',
|
||||
avgLatency: 'Avg Latency',
|
||||
errorRate: 'Error Rate',
|
||||
activeConnections: 'Active Connections',
|
||||
cacheStatus: 'Cache Hit Rate',
|
||||
},
|
||||
circuitBreaker: {
|
||||
title: 'Circuit Breaker Status',
|
||||
healthy: 'Healthy',
|
||||
degraded: 'Degraded',
|
||||
unhealthy: 'Unhealthy',
|
||||
failureRate: 'Failure Rate',
|
||||
lastFailure: 'Last Failure',
|
||||
failures: 'Failures',
|
||||
},
|
||||
trafficLog: {
|
||||
title: 'Traffic Logs',
|
||||
direction: 'Filter by direction',
|
||||
all: 'All directions',
|
||||
inbound: 'Inbound',
|
||||
outbound: 'Outbound',
|
||||
exportJson: 'Export JSON',
|
||||
exportCsv: 'Export CSV',
|
||||
noLogs: 'No traffic logs found',
|
||||
timestamp: 'Timestamp',
|
||||
endpoint: 'Endpoint',
|
||||
method: 'Method',
|
||||
status: 'Status',
|
||||
duration: 'Duration',
|
||||
},
|
||||
tools: {
|
||||
totalRequests: 'Total Requests',
|
||||
matchedRequests: 'Matched Requests',
|
||||
parityViolations: 'Parity Violations',
|
||||
ideSetup: 'IDE Quick Setup',
|
||||
copyConfig: 'Copy Config',
|
||||
requestReplay: 'Request Replay',
|
||||
comingSoon: 'Coming soon',
|
||||
},
|
||||
trafficLogs: {
|
||||
title: 'Traffic Logs',
|
||||
filterDirection: 'Filter by direction',
|
||||
allDirections: 'All directions',
|
||||
exportJson: 'Export JSON',
|
||||
exportCsv: 'Export CSV',
|
||||
noLogs: 'No traffic logs found',
|
||||
},
|
||||
parity: {
|
||||
title: 'Parity Counters',
|
||||
requests: 'Parity Requests',
|
||||
errors: 'Parity Errors',
|
||||
shadowComparisons: 'Shadow Comparisons',
|
||||
shadowMismatches: 'Shadow Mismatches',
|
||||
noGoBlocked: 'No-Go Blocked',
|
||||
},
|
||||
ideSetup: {
|
||||
title: 'IDE Quick Setup',
|
||||
cursor: 'Cursor',
|
||||
vscode: 'VS Code',
|
||||
claudeCode: 'Claude Code',
|
||||
copyConfig: 'Copy Config',
|
||||
configCopied: 'Config copied!',
|
||||
},
|
||||
replay: {
|
||||
title: 'Request Replay',
|
||||
description: 'Replay recent requests for debugging',
|
||||
refresh: 'Refresh',
|
||||
recentRequests: 'Recent Requests',
|
||||
clickToReplay: 'Click the play button to replay any request',
|
||||
noRequests: 'No recent requests',
|
||||
replay: 'Replay',
|
||||
success: 'Replay Successful',
|
||||
replayedSuccessfully: 'Request was replayed successfully',
|
||||
error: 'Replay Failed',
|
||||
unknownError: 'An unknown error occurred',
|
||||
pending: 'Pending',
|
||||
comingSoon: 'Coming soon',
|
||||
},
|
||||
capabilities: {
|
||||
title: 'Model Capabilities',
|
||||
description: 'Discover what each model can do',
|
||||
},
|
||||
tabs: {
|
||||
tools: 'Tools',
|
||||
replay: 'Replay',
|
||||
capabilities: 'Capabilities',
|
||||
},
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
title: 'Token Usage',
|
||||
description: 'Monitor your API token consumption over time.',
|
||||
range24h: 'Last 24 Hours',
|
||||
range7d: 'Last 7 Days',
|
||||
range30d: 'Last 30 Days',
|
||||
totalTokens: 'Total Tokens',
|
||||
promptTokens: 'Prompt Tokens',
|
||||
completionTokens: 'Completion Tokens',
|
||||
totalRequests: 'Total Requests',
|
||||
dailyUsage: 'Daily Usage',
|
||||
byModel: 'Usage by Model',
|
||||
hourlyUsage: 'Hourly Usage',
|
||||
loadFailed: 'Failed to load usage data.',
|
||||
retry: 'Retry',
|
||||
allAccounts: 'All Accounts',
|
||||
noData: 'No data available',
|
||||
emptyStateTitle: 'No usage data yet',
|
||||
emptyStateDescription:
|
||||
'Start making API requests to see your token usage statistics here.',
|
||||
promptLabel: 'Prompt',
|
||||
completionLabel: 'Completion',
|
||||
totalLabel: 'Total',
|
||||
requestsLabel: 'Requests',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -540,6 +669,7 @@ i18n
|
||||
},
|
||||
nav: {
|
||||
accounts: '账号',
|
||||
usage: '用量',
|
||||
proxy: 'API 反代',
|
||||
settings: '设置',
|
||||
},
|
||||
@@ -963,10 +1093,137 @@ i18n
|
||||
maps_to: '映射到',
|
||||
restore: '恢复默认',
|
||||
},
|
||||
routing: {
|
||||
currentMappings: '当前模型映射',
|
||||
modelMapping: 'Anthropic 模型路由配置',
|
||||
noMapping: '未配置模型映射。',
|
||||
upstreamProxy: '出站代理配置',
|
||||
status: '状态',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
url: 'URL',
|
||||
},
|
||||
examples: {
|
||||
title: '使用示例',
|
||||
description: '调用本地 API 代理的示例命令。',
|
||||
},
|
||||
advanced: {
|
||||
title: '高级',
|
||||
monitor: '监控',
|
||||
metrics: {
|
||||
title: '性能指标',
|
||||
requestsPerMinute: '请求/分钟',
|
||||
avgLatency: '平均延迟',
|
||||
errorRate: '错误率',
|
||||
activeConnections: '活跃连接',
|
||||
cacheStatus: '缓存命中率',
|
||||
},
|
||||
circuitBreaker: {
|
||||
title: '熔断器状态',
|
||||
healthy: '健康',
|
||||
degraded: '降级',
|
||||
unhealthy: '不健康',
|
||||
failureRate: '失败率',
|
||||
lastFailure: '上次失败',
|
||||
failures: '失败次数',
|
||||
},
|
||||
trafficLog: {
|
||||
title: '流量日志',
|
||||
direction: '按方向筛选',
|
||||
all: '所有方向',
|
||||
inbound: '入站',
|
||||
outbound: '出站',
|
||||
exportJson: '导出 JSON',
|
||||
exportCsv: '导出 CSV',
|
||||
noLogs: '暂无流量日志',
|
||||
timestamp: '时间戳',
|
||||
endpoint: '端点',
|
||||
method: '方法',
|
||||
status: '状态',
|
||||
duration: '耗时',
|
||||
},
|
||||
tools: {
|
||||
totalRequests: '总请求数',
|
||||
matchedRequests: '匹配请求数',
|
||||
parityViolations: '一致性违规',
|
||||
ideSetup: 'IDE 快速配置',
|
||||
copyConfig: '复制配置',
|
||||
requestReplay: '请求重放',
|
||||
comingSoon: '即将推出',
|
||||
},
|
||||
trafficLogs: {
|
||||
title: '流量日志',
|
||||
filterDirection: '按方向筛选',
|
||||
allDirections: '所有方向',
|
||||
exportJson: '导出 JSON',
|
||||
exportCsv: '导出 CSV',
|
||||
noLogs: '暂无流量日志',
|
||||
},
|
||||
parity: {
|
||||
title: '一致性计数器',
|
||||
requests: '一致性请求',
|
||||
errors: '一致性错误',
|
||||
shadowComparisons: '影子对比',
|
||||
shadowMismatches: '影子不匹配',
|
||||
noGoBlocked: '禁止通过',
|
||||
},
|
||||
ideSetup: {
|
||||
title: 'IDE 快速配置',
|
||||
cursor: 'Cursor',
|
||||
vscode: 'VS Code',
|
||||
claudeCode: 'Claude Code',
|
||||
copyConfig: '复制配置',
|
||||
configCopied: '配置已复制!',
|
||||
},
|
||||
replay: {
|
||||
title: '请求重放',
|
||||
description: '重放最近的请求以进行调试',
|
||||
refresh: '刷新',
|
||||
recentRequests: '最近请求',
|
||||
clickToReplay: '点击播放按钮重放任何请求',
|
||||
noRequests: '暂无最近请求',
|
||||
replay: '重放',
|
||||
success: '重放成功',
|
||||
replayedSuccessfully: '请求已成功重放',
|
||||
error: '重放失败',
|
||||
unknownError: '发生未知错误',
|
||||
pending: '待定',
|
||||
comingSoon: '即将推出',
|
||||
},
|
||||
capabilities: {
|
||||
title: '模型能力',
|
||||
description: '发现每个模型可以做什么',
|
||||
},
|
||||
tabs: {
|
||||
tools: '工具',
|
||||
replay: '重放',
|
||||
capabilities: '能力',
|
||||
},
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
title: 'Token 用量',
|
||||
description: '监控您的 API Token 消耗情况。',
|
||||
range24h: '最近 24 小时',
|
||||
range7d: '最近 7 天',
|
||||
range30d: '最近 30 天',
|
||||
totalTokens: '总 Token 数',
|
||||
promptTokens: 'Prompt Token 数',
|
||||
completionTokens: 'Completion Token 数',
|
||||
totalRequests: '总请求数',
|
||||
dailyUsage: '每日用量',
|
||||
byModel: '按模型统计',
|
||||
hourlyUsage: '每小时用量',
|
||||
loadFailed: '加载用量数据失败。',
|
||||
retry: '重试',
|
||||
allAccounts: '所有账号',
|
||||
noData: '暂无数据',
|
||||
emptyStateTitle: '暂无用量数据',
|
||||
emptyStateDescription: '开始发起 API 请求后,此处将显示您的 Token 用量统计。',
|
||||
promptLabel: 'Prompt',
|
||||
completionLabel: 'Completion',
|
||||
totalLabel: '总计',
|
||||
requestsLabel: '请求数',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1012,6 +1269,7 @@ i18n
|
||||
},
|
||||
nav: {
|
||||
accounts: 'Аккаунты',
|
||||
usage: 'Использование',
|
||||
proxy: 'API Прокси',
|
||||
settings: 'Настройки',
|
||||
},
|
||||
@@ -1444,10 +1702,138 @@ i18n
|
||||
maps_to: '->',
|
||||
restore: 'Сброс',
|
||||
},
|
||||
routing: {
|
||||
currentMappings: 'Текущие сопоставления моделей',
|
||||
modelMapping: 'Конфигурация маршрутизации моделей Anthropic',
|
||||
noMapping: 'Сопоставления моделей не настроены.',
|
||||
upstreamProxy: 'Конфигурация исходящего прокси',
|
||||
status: 'Статус',
|
||||
enabled: 'Включено',
|
||||
disabled: 'Отключено',
|
||||
url: 'URL',
|
||||
},
|
||||
examples: {
|
||||
title: 'Примеры',
|
||||
description: 'Примеры использования локального прокси.',
|
||||
},
|
||||
advanced: {
|
||||
title: 'Расширенные',
|
||||
monitor: 'Монитор',
|
||||
metrics: {
|
||||
title: 'Метрики производительности',
|
||||
requestsPerMinute: 'Запросов/мин',
|
||||
avgLatency: 'Средняя задержка',
|
||||
errorRate: 'Частота ошибок',
|
||||
activeConnections: 'Активные соединения',
|
||||
cacheStatus: 'Попадание в кэш',
|
||||
},
|
||||
circuitBreaker: {
|
||||
title: 'Состояние предохранителя',
|
||||
healthy: 'Здоровый',
|
||||
degraded: 'Деградированный',
|
||||
unhealthy: 'Нездоровый',
|
||||
failureRate: 'Частота отказов',
|
||||
lastFailure: 'Последний сбой',
|
||||
failures: 'Сбои',
|
||||
},
|
||||
trafficLog: {
|
||||
title: 'Журналы трафика',
|
||||
direction: 'Фильтр по направлению',
|
||||
all: 'Все направления',
|
||||
inbound: 'Входящий',
|
||||
outbound: 'Исходящий',
|
||||
exportJson: 'Экспорт JSON',
|
||||
exportCsv: 'Экспорт CSV',
|
||||
noLogs: 'Журналы трафика не найдены',
|
||||
timestamp: 'Время',
|
||||
endpoint: 'Конечная точка',
|
||||
method: 'Метод',
|
||||
status: 'Статус',
|
||||
duration: 'Длительность',
|
||||
},
|
||||
tools: {
|
||||
totalRequests: 'Всего запросов',
|
||||
matchedRequests: 'Совпавшие запросы',
|
||||
parityViolations: 'Нарушения паритета',
|
||||
ideSetup: 'Быстрая настройка IDE',
|
||||
copyConfig: 'Копировать конфиг',
|
||||
requestReplay: 'Повтор запросов',
|
||||
comingSoon: 'Скоро',
|
||||
},
|
||||
trafficLogs: {
|
||||
title: 'Журналы трафика',
|
||||
filterDirection: 'Фильтр по направлению',
|
||||
allDirections: 'Все направления',
|
||||
exportJson: 'Экспорт JSON',
|
||||
exportCsv: 'Экспорт CSV',
|
||||
noLogs: 'Журналы трафика не найдены',
|
||||
},
|
||||
parity: {
|
||||
title: 'Счетчики паритета',
|
||||
requests: 'Запросы паритета',
|
||||
errors: 'Ошибки паритета',
|
||||
shadowComparisons: 'Теневые сравнения',
|
||||
shadowMismatches: 'Теневые несоответствия',
|
||||
noGoBlocked: 'Блокировка No-Go',
|
||||
},
|
||||
ideSetup: {
|
||||
title: 'Быстрая настройка IDE',
|
||||
cursor: 'Cursor',
|
||||
vscode: 'VS Code',
|
||||
claudeCode: 'Claude Code',
|
||||
copyConfig: 'Копировать конфиг',
|
||||
configCopied: 'Конфиг скопирован!',
|
||||
},
|
||||
replay: {
|
||||
title: 'Повтор запросов',
|
||||
description: 'Повтор недавних запросов для отладки',
|
||||
refresh: 'Обновить',
|
||||
recentRequests: 'Недавние запросы',
|
||||
clickToReplay: 'Нажмите кнопку воспроизведения, чтобы повторить любой запрос',
|
||||
noRequests: 'Нет недавних запросов',
|
||||
replay: 'Повторить',
|
||||
success: 'Повтор успешен',
|
||||
replayedSuccessfully: 'Запрос успешно повторен',
|
||||
error: 'Ошибка повтора',
|
||||
unknownError: 'Произошла неизвестная ошибка',
|
||||
pending: 'В ожидании',
|
||||
comingSoon: 'Скоро',
|
||||
},
|
||||
capabilities: {
|
||||
title: 'Возможности моделей',
|
||||
description: 'Узнайте, что может каждая модель',
|
||||
},
|
||||
tabs: {
|
||||
tools: 'Инструменты',
|
||||
replay: 'Повтор',
|
||||
capabilities: 'Возможности',
|
||||
},
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
title: 'Использование токенов',
|
||||
description: 'Мониторинг потребления API токенов.',
|
||||
range24h: 'Последние 24 часа',
|
||||
range7d: 'Последние 7 дней',
|
||||
range30d: 'Последние 30 дней',
|
||||
totalTokens: 'Всего токенов',
|
||||
promptTokens: 'Prompt токены',
|
||||
completionTokens: 'Completion токены',
|
||||
totalRequests: 'Всего запросов',
|
||||
dailyUsage: 'Ежедневное использование',
|
||||
byModel: 'По моделям',
|
||||
hourlyUsage: 'Почасовое использование',
|
||||
loadFailed: 'Не удалось загрузить данные об использовании.',
|
||||
retry: 'Повторить',
|
||||
allAccounts: 'Все аккаунты',
|
||||
noData: 'Нет данных',
|
||||
emptyStateTitle: 'Данных об использовании пока нет',
|
||||
emptyStateDescription:
|
||||
'Начните отправлять API-запросы, чтобы увидеть статистику использования токенов.',
|
||||
promptLabel: 'Prompt',
|
||||
completionLabel: 'Completion',
|
||||
totalLabel: 'Всего',
|
||||
requestsLabel: 'Запросы',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@ const vi = {
|
||||
},
|
||||
nav: {
|
||||
accounts: 'Tài khoản',
|
||||
usage: 'Sử dụng',
|
||||
proxy: 'API Proxy',
|
||||
settings: 'Cài đặt',
|
||||
},
|
||||
@@ -471,6 +472,79 @@ const vi = {
|
||||
title: 'Ví dụ sử dụng',
|
||||
description: 'Ví dụ lệnh để gọi local API proxy.',
|
||||
},
|
||||
advanced: {
|
||||
title: 'Nâng cao',
|
||||
monitor: 'Giám sát',
|
||||
routing: 'Định tuyến',
|
||||
tools: 'Công cụ',
|
||||
metrics: {
|
||||
title: 'Chỉ số hiệu suất',
|
||||
requestsPerMinute: 'Yêu cầu/phút',
|
||||
avgLatency: 'Độ trễ TB',
|
||||
errorRate: 'Tỷ lệ lỗi',
|
||||
activeConnections: 'Kết nối hoạt động',
|
||||
},
|
||||
circuitBreaker: {
|
||||
title: 'Trạng thái Circuit Breaker',
|
||||
healthy: 'Khỏe',
|
||||
degraded: 'Suy giảm',
|
||||
unhealthy: 'Không khỏe',
|
||||
failureRate: 'Tỷ lệ thất bại',
|
||||
lastFailure: 'Lỗi gần nhất',
|
||||
},
|
||||
trafficLogs: {
|
||||
title: 'Nhật ký lưu lượng',
|
||||
filterDirection: 'Lọc theo hướng',
|
||||
allDirections: 'Tất cả hướng',
|
||||
exportJson: 'Xuất JSON',
|
||||
exportCsv: 'Xuất CSV',
|
||||
noLogs: 'Không tìm thấy nhật ký',
|
||||
},
|
||||
parity: {
|
||||
title: 'Bộ đếm Parity',
|
||||
requests: 'Yêu cầu Parity',
|
||||
errors: 'Lỗi Parity',
|
||||
shadowComparisons: 'So sánh Shadow',
|
||||
shadowMismatches: 'Không khớp Shadow',
|
||||
noGoBlocked: 'No-Go đã chặn',
|
||||
},
|
||||
ideSetup: {
|
||||
title: 'Thiết lập IDE nhanh',
|
||||
cursor: 'Cursor',
|
||||
vscode: 'VS Code',
|
||||
claudeCode: 'Claude Code',
|
||||
copyConfig: 'Sao chép cấu hình',
|
||||
configCopied: 'Đã sao chép cấu hình!',
|
||||
},
|
||||
replay: {
|
||||
title: 'Phát lại yêu cầu',
|
||||
comingSoon: 'Sắp ra mắt',
|
||||
},
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
title: 'Token Usage',
|
||||
description: 'Theo dõi mức tiêu thụ API token của bạn.',
|
||||
range24h: '24 giờ qua',
|
||||
range7d: '7 ngày qua',
|
||||
range30d: '30 ngày qua',
|
||||
totalTokens: 'Tổng Token',
|
||||
promptTokens: 'Prompt Token',
|
||||
completionTokens: 'Completion Token',
|
||||
totalRequests: 'Tổng Request',
|
||||
dailyUsage: 'Sử dụng hàng ngày',
|
||||
byModel: 'Theo Model',
|
||||
hourlyUsage: 'Sử dụng theo giờ',
|
||||
loadFailed: 'Không thể tải dữ liệu sử dụng.',
|
||||
retry: 'Thử lại',
|
||||
allAccounts: 'Tất cả tài khoản',
|
||||
noData: 'Không có dữ liệu',
|
||||
emptyStateTitle: 'Chưa có dữ liệu sử dụng',
|
||||
emptyStateDescription: 'Bắt đầu gửi yêu cầu API để xem thống kê sử dụng token tại đây.',
|
||||
promptLabel: 'Prompt',
|
||||
completionLabel: 'Completion',
|
||||
totalLabel: 'Tổng',
|
||||
requestsLabel: 'Yêu cầu',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
|
||||
// Use process.cwd() in dev to find the icon reliably
|
||||
icon: inDevelopment
|
||||
? path.join(process.cwd(), 'src/assets/icon.png')
|
||||
: path.join(__dirname, '../assets/icon.png'),
|
||||
: path.join(process.resourcesPath, 'assets', 'icon.png'),
|
||||
});
|
||||
globalMainWindow = mainWindow;
|
||||
logger.info('createWindow: BrowserWindow instance created');
|
||||
|
||||
@@ -9,10 +9,16 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as UsageRouteImport } from './routes/usage'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as ProxyRouteImport } from './routes/proxy'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const UsageRoute = UsageRouteImport.update({
|
||||
id: '/usage',
|
||||
path: '/usage',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
@@ -33,34 +39,45 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/proxy': typeof ProxyRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/usage': typeof UsageRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/proxy': typeof ProxyRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/usage': typeof UsageRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/proxy': typeof ProxyRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/usage': typeof UsageRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/proxy' | '/settings'
|
||||
fullPaths: '/' | '/proxy' | '/settings' | '/usage'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/proxy' | '/settings'
|
||||
id: '__root__' | '/' | '/proxy' | '/settings'
|
||||
to: '/' | '/proxy' | '/settings' | '/usage'
|
||||
id: '__root__' | '/' | '/proxy' | '/settings' | '/usage'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ProxyRoute: typeof ProxyRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
UsageRoute: typeof UsageRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/usage': {
|
||||
id: '/usage'
|
||||
path: '/usage'
|
||||
fullPath: '/usage'
|
||||
preLoaderRoute: typeof UsageRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
@@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ProxyRoute: ProxyRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
UsageRoute: UsageRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -35,9 +35,9 @@ function HomePageErrorBoundary({ error, reset }: { error: unknown; reset: () =>
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl p-6">
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<div className="text-lg font-semibold">{t('error.generic')}</div>
|
||||
<div className="text-muted-foreground mt-2 text-sm">{t('action.retry')}</div>
|
||||
<div className="rounded-xl border border-dashed border-white/[0.1] p-8 text-center">
|
||||
<div className="text-base font-semibold">{t('error.generic')}</div>
|
||||
<div className="text-muted-foreground mt-2 text-[13px]">{t('action.retry')}</div>
|
||||
<Button className="mt-4" variant="outline" onClick={() => reset()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('action.retry')}
|
||||
|
||||
@@ -79,8 +79,8 @@ function SettingsPage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl space-y-5 p-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">{t('settings.title')}</h2>
|
||||
<p className="text-muted-foreground mt-1">{t('settings.description')}</p>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{t('settings.title')}</h2>
|
||||
<p className="text-muted-foreground mt-1 text-[13px]">{t('settings.description')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
@@ -92,16 +92,16 @@ function SettingsPage() {
|
||||
|
||||
{/* --- GENERAL TAB --- */}
|
||||
<TabsContent value="general" className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.appearance.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.appearance.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dark-mode">{t('settings.darkMode')}</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.darkModeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -139,8 +139,8 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Account Settings Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.account.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.account.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -149,7 +149,7 @@ 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-xs text-gray-500">{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 +165,7 @@ 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-xs text-gray-500">{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,8 +180,8 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{isAutoStartSupported && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.startup.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.startup.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -189,7 +189,7 @@ function SettingsPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.startup.auto_startup')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.startup.auto_startup_desc')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -211,13 +211,13 @@ function SettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.about.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.about.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4 text-[13px]">
|
||||
<div className="text-muted-foreground">{t('settings.version')}</div>
|
||||
<div className="font-medium">{appVersion || t('common.unknown')}</div>
|
||||
|
||||
@@ -230,7 +230,7 @@ function SettingsPage() {
|
||||
<div className="text-muted-foreground">{t('action.openLogs')}</div>
|
||||
<button
|
||||
onClick={() => openLogDirectory()}
|
||||
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
className="flex items-center gap-2 font-medium text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span>{t('settings.openLogDir')}</span>
|
||||
@@ -240,8 +240,8 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Privacy & Error Reporting Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.privacy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.privacy.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -249,7 +249,7 @@ function SettingsPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.privacy.error_reporting')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.privacy.error_reporting_desc')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -267,8 +267,8 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Notifications Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.notifications.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.notifications.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -276,7 +276,7 @@ function SettingsPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.notifications.quotaAlert')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.notifications.quotaAlertDesc')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@ function SettingsPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.notifications.quotaThreshold')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.notifications.quotaThresholdDesc')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -327,7 +327,7 @@ function SettingsPage() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-16 rounded-md border bg-transparent px-2 py-1 text-center text-sm"
|
||||
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"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">%</span>
|
||||
</div>
|
||||
@@ -343,8 +343,8 @@ function SettingsPage() {
|
||||
|
||||
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
||||
<TabsContent value="proxy" className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.proxy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.proxy.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ResponsiveLine } from '@nivo/line';
|
||||
import { ResponsiveBar } from '@nivo/bar';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { ChartCard } from '@/components/usage/ChartCard';
|
||||
import {
|
||||
COLOR_PROMPT,
|
||||
COLOR_COMPLETION,
|
||||
PIE_COLORS,
|
||||
LineTooltip,
|
||||
} from '@/components/usage/NivoTooltip';
|
||||
import { nivoTheme } from '@/lib/nivo-theme';
|
||||
import {
|
||||
Activity,
|
||||
Layers,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type TimeRange = '24h' | '7d' | '30d';
|
||||
|
||||
interface UsageBucket {
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
interface ModelBucket {
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function getTimeRange(r: TimeRange): { start: number; end: number } {
|
||||
const now = Date.now();
|
||||
const ms: Record<TimeRange, number> = {
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return { start: now - ms[r], end: now };
|
||||
}
|
||||
|
||||
function getPreviousTimeRange(r: TimeRange): { start: number; end: number } {
|
||||
const { start, end } = getTimeRange(r);
|
||||
const duration = end - start;
|
||||
return { start: start - duration, end: start };
|
||||
}
|
||||
|
||||
function formatBucketLabel(bucket: string, range: TimeRange): string {
|
||||
if (range === '24h') {
|
||||
const parts = bucket.split(' ');
|
||||
return parts.length === 2 ? parts[1] : bucket;
|
||||
}
|
||||
const d = new Date(bucket + 'T00:00:00');
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) {
|
||||
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (n >= 1_000) {
|
||||
return `${(n / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function calcTrend(
|
||||
current: number,
|
||||
previous: number,
|
||||
): { value: number; direction: 'up' | 'down' | 'neutral' } {
|
||||
if (previous === 0 && current === 0) {
|
||||
return { value: 0, direction: 'neutral' };
|
||||
}
|
||||
if (previous === 0) {
|
||||
return { value: 100, direction: 'up' };
|
||||
}
|
||||
const pct = ((current - previous) / previous) * 100;
|
||||
if (Math.abs(pct) < 0.5) {
|
||||
return { value: 0, direction: 'neutral' };
|
||||
}
|
||||
return { value: Math.abs(pct), direction: pct > 0 ? 'up' : 'down' };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Aggregation helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function sumBuckets(data: UsageBucket[]) {
|
||||
return data.reduce(
|
||||
(acc, row) => ({
|
||||
promptTokens: acc.promptTokens + row.promptTokens,
|
||||
completionTokens: acc.completionTokens + row.completionTokens,
|
||||
totalTokens: acc.totalTokens + row.totalTokens,
|
||||
requests: acc.requests + row.requests,
|
||||
}),
|
||||
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, requests: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function UsagePage() {
|
||||
const [range, setRange] = useState<TimeRange>('7d');
|
||||
const ALL_ACCOUNTS = '__all__';
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||
|
||||
const accountFilter =
|
||||
selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
|
||||
|
||||
/* ───── Current period data ───── */
|
||||
const {
|
||||
data: dailyData,
|
||||
isLoading: dailyLoading,
|
||||
error: dailyError,
|
||||
refetch: refetchDaily,
|
||||
} = useQuery<UsageBucket[]>({
|
||||
queryKey: ['usage', 'day', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getTimeRange(range);
|
||||
return ipc.client.usage.getUsageByDay({
|
||||
accountId: accountFilter,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
},
|
||||
refetchInterval: 15_000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: prevDailyData } = useQuery<UsageBucket[]>({
|
||||
queryKey: ['usage', 'day', 'prev', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getPreviousTimeRange(range);
|
||||
return ipc.client.usage.getUsageByDay({
|
||||
accountId: accountFilter,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: modelData,
|
||||
isLoading: modelLoading,
|
||||
error: modelError,
|
||||
refetch: refetchModel,
|
||||
} = useQuery<ModelBucket[]>({
|
||||
queryKey: ['usage', 'model', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getTimeRange(range);
|
||||
return ipc.client.usage.getUsageByModel({
|
||||
accountId: accountFilter,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
},
|
||||
refetchInterval: 15_000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: hourlyData,
|
||||
isLoading: hourlyLoading,
|
||||
error: hourlyError,
|
||||
refetch: refetchHourly,
|
||||
} = useQuery<UsageBucket[]>({
|
||||
queryKey: ['usage', 'hour', range, selectedAccountId],
|
||||
queryFn: () => {
|
||||
const { start, end } = getTimeRange(range);
|
||||
return ipc.client.usage.getUsageByHour({
|
||||
accountId: accountFilter,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
},
|
||||
enabled: range === '24h',
|
||||
refetchInterval: 15_000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
/* ───── Derived data ───── */
|
||||
const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]);
|
||||
const prevTotals = useMemo(
|
||||
() => sumBuckets(prevDailyData || []),
|
||||
[prevDailyData],
|
||||
);
|
||||
|
||||
const promptRatio = totals.totalTokens
|
||||
? Math.round((totals.promptTokens / totals.totalTokens) * 100)
|
||||
: 0;
|
||||
const completionRatio = totals.totalTokens
|
||||
? Math.round((totals.completionTokens / totals.totalTokens) * 100)
|
||||
: 0;
|
||||
const avgReqSize = totals.requests
|
||||
? Math.round(totals.totalTokens / totals.requests)
|
||||
: 0;
|
||||
|
||||
const sparklineTotal = useMemo(
|
||||
() => (dailyData || []).map((d) => d.totalTokens),
|
||||
[dailyData],
|
||||
);
|
||||
const sparklinePrompt = useMemo(
|
||||
() => (dailyData || []).map((d) => d.promptTokens),
|
||||
[dailyData],
|
||||
);
|
||||
const sparklineCompletion = useMemo(
|
||||
() => (dailyData || []).map((d) => d.completionTokens),
|
||||
[dailyData],
|
||||
);
|
||||
const sparklineRequests = useMemo(
|
||||
() => (dailyData || []).map((d) => d.requests),
|
||||
[dailyData],
|
||||
);
|
||||
|
||||
// Area chart — Nivo line format
|
||||
const areaChartData = useMemo(() => {
|
||||
const source = range === '24h' ? hourlyData || [] : dailyData || [];
|
||||
return [
|
||||
{
|
||||
id: 'Prompt',
|
||||
color: COLOR_PROMPT,
|
||||
data: source.map((d) => ({
|
||||
x: formatBucketLabel(d.bucket, range),
|
||||
y: d.promptTokens,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'Completion',
|
||||
color: COLOR_COMPLETION,
|
||||
data: source.map((d) => ({
|
||||
x: formatBucketLabel(d.bucket, range),
|
||||
y: d.completionTokens,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}, [dailyData, hourlyData, range]);
|
||||
|
||||
// Model ranking bar data
|
||||
const modelBarData = useMemo(() => {
|
||||
if (!modelData?.length) {
|
||||
return [];
|
||||
}
|
||||
return [...modelData]
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 8)
|
||||
.map((m) => ({
|
||||
model:
|
||||
m.model.length > 22 ? m.model.slice(0, 22) + '…' : m.model,
|
||||
tokens: m.totalTokens,
|
||||
}));
|
||||
}, [modelData]);
|
||||
|
||||
// Top models mini-list
|
||||
const topModels = useMemo(() => {
|
||||
if (!modelData?.length) {
|
||||
return [];
|
||||
}
|
||||
return [...modelData]
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 5);
|
||||
}, [modelData]);
|
||||
|
||||
const isAreaEmpty =
|
||||
range === '24h'
|
||||
? !hourlyLoading && !hourlyError && !hourlyData?.length
|
||||
: !dailyLoading && !dailyError && !dailyData?.length;
|
||||
const areaLoading = range === '24h' ? hourlyLoading : dailyLoading;
|
||||
const areaError = range === '24h' ? hourlyError : dailyError;
|
||||
const isModelEmpty = !modelLoading && !modelError && !modelData?.length;
|
||||
|
||||
/* ───── Render ───── */
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
{/* ─── Header ─── */}
|
||||
<div className="flex flex-col gap-4 border-b border-white/[0.06] px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-foreground text-lg font-semibold tracking-tight">
|
||||
Usage Analytics
|
||||
</h1>
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
|
||||
<Radio className="h-2.5 w-2.5 animate-pulse text-emerald-400" />
|
||||
<span className="text-[10px] font-medium text-emerald-400">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-[13px]">
|
||||
Token consumption and model distribution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={selectedAccountId}
|
||||
onValueChange={setSelectedAccountId}
|
||||
disabled={accountsLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
{accountsLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading…
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="All accounts" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ACCOUNTS}>All accounts</SelectItem>
|
||||
{accounts?.map((acc) => (
|
||||
<SelectItem key={acc.id} value={acc.id}>
|
||||
{acc.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tabs
|
||||
value={range}
|
||||
onValueChange={(v) => setRange(v as TimeRange)}
|
||||
>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="24h" className="px-3 text-xs">
|
||||
24h
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="7d" className="px-3 text-xs">
|
||||
7d
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="30d" className="px-3 text-xs">
|
||||
30d
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 px-6 py-5">
|
||||
{/* ─── Stat cards ─── */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="Total Tokens"
|
||||
value={formatNumber(totals.totalTokens)}
|
||||
icon={Zap}
|
||||
isLoading={dailyLoading}
|
||||
accent="amber"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(totals.totalTokens, prevTotals.totalTokens)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklineTotal}
|
||||
/>
|
||||
<StatCard
|
||||
label="Prompt Tokens"
|
||||
value={formatNumber(totals.promptTokens)}
|
||||
icon={MessageSquare}
|
||||
isLoading={dailyLoading}
|
||||
accent="blue"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklinePrompt}
|
||||
/>
|
||||
<StatCard
|
||||
label="Completion Tokens"
|
||||
value={formatNumber(totals.completionTokens)}
|
||||
icon={Activity}
|
||||
isLoading={dailyLoading}
|
||||
accent="green"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(
|
||||
totals.completionTokens,
|
||||
prevTotals.completionTokens,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklineCompletion}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Requests"
|
||||
value={totals.requests.toLocaleString()}
|
||||
icon={Layers}
|
||||
isLoading={dailyLoading}
|
||||
accent="slate"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(totals.requests, prevTotals.requests)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklineRequests}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ─── Prompt/Completion ratio bar + top models ─── */}
|
||||
{!dailyLoading && totals.totalTokens > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
<div className="col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] bg-card p-4 lg:col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
||||
Prompt / Completion
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(totals.totalTokens)} total
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<div
|
||||
className="h-full rounded-l-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${promptRatio}%`,
|
||||
backgroundColor: COLOR_PROMPT,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-full rounded-r-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${completionRatio}%`,
|
||||
backgroundColor: COLOR_COMPLETION,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: COLOR_PROMPT }}
|
||||
/>
|
||||
<span className="text-muted-foreground">Prompt</span>
|
||||
<span className="font-mono font-semibold text-foreground">
|
||||
{promptRatio}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: COLOR_COMPLETION }}
|
||||
/>
|
||||
<span className="text-muted-foreground">Completion</span>
|
||||
<span className="font-mono font-semibold text-foreground">
|
||||
{completionRatio}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Avg/req:{' '}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{formatNumber(avgReqSize)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top models */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
||||
Top Models
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
tokens
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{topModels.map((m, i) => {
|
||||
const max = topModels[0]?.totalTokens || 1;
|
||||
const pct = Math.max(8, (m.totalTokens / max) * 100);
|
||||
return (
|
||||
<div key={m.model} className="group">
|
||||
<div className="mb-0.5 flex items-center justify-between">
|
||||
<span className="max-w-[140px] truncate text-xs text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
{m.model}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] font-medium text-foreground tabular-nums">
|
||||
{formatNumber(m.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor:
|
||||
PIE_COLORS[i % PIE_COLORS.length],
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{topModels.length === 0 && (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
No data
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Charts — Area + Model ranking ─── */}
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-5">
|
||||
<div className="xl:col-span-3">
|
||||
<ChartCard
|
||||
title={range === '24h' ? 'Hourly Usage' : 'Daily Usage'}
|
||||
isLoading={areaLoading}
|
||||
error={areaError}
|
||||
isEmpty={isAreaEmpty}
|
||||
onRetry={() =>
|
||||
range === '24h' ? refetchHourly() : refetchDaily()
|
||||
}
|
||||
emptyTitle="No usage data"
|
||||
emptyDescription="Data will appear here once proxy requests are recorded."
|
||||
errorMessage="Failed to load usage data"
|
||||
retryLabel="Retry"
|
||||
>
|
||||
<div className="h-[320px]">
|
||||
<ResponsiveLine
|
||||
data={areaChartData}
|
||||
theme={nivoTheme}
|
||||
colors={[COLOR_PROMPT, COLOR_COMPLETION]}
|
||||
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
|
||||
xScale={{ type: 'point' }}
|
||||
yScale={{ type: 'linear', min: 0, stacked: false }}
|
||||
curve="monotoneX"
|
||||
enableArea={true}
|
||||
areaOpacity={0.08}
|
||||
areaBlendMode="normal"
|
||||
enablePoints={true}
|
||||
pointSize={4}
|
||||
pointColor={{ theme: 'background' }}
|
||||
pointBorderWidth={2}
|
||||
pointBorderColor={{ from: 'serieColor' }}
|
||||
enableGridX={false}
|
||||
enableGridY={true}
|
||||
gridYValues={5}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
tickRotation: 0,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
tickRotation: 0,
|
||||
format: (v: number) =>
|
||||
v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`,
|
||||
}}
|
||||
tooltip={LineTooltip}
|
||||
legends={[
|
||||
{
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
justify: false,
|
||||
translateX: 0,
|
||||
translateY: 36,
|
||||
itemsSpacing: 16,
|
||||
itemWidth: 80,
|
||||
itemHeight: 16,
|
||||
itemDirection: 'left-to-right',
|
||||
symbolSize: 8,
|
||||
symbolShape: 'circle',
|
||||
},
|
||||
]}
|
||||
animate={true}
|
||||
motionConfig="gentle"
|
||||
/>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2">
|
||||
<ChartCard
|
||||
title="Model Distribution"
|
||||
isLoading={modelLoading}
|
||||
error={modelError}
|
||||
isEmpty={isModelEmpty}
|
||||
onRetry={() => refetchModel()}
|
||||
emptyTitle="No model data"
|
||||
emptyDescription="Model distribution will appear after proxy usage."
|
||||
errorMessage="Failed to load model data"
|
||||
retryLabel="Retry"
|
||||
>
|
||||
<div className="h-[320px]">
|
||||
<ResponsiveBar
|
||||
data={modelBarData}
|
||||
keys={['tokens']}
|
||||
indexBy="model"
|
||||
layout="horizontal"
|
||||
theme={nivoTheme}
|
||||
colors={PIE_COLORS}
|
||||
colorBy="indexValue"
|
||||
margin={{ top: 0, right: 10, bottom: 10, left: 120 }}
|
||||
padding={0.3}
|
||||
borderRadius={4}
|
||||
enableGridX={true}
|
||||
enableGridY={false}
|
||||
gridXValues={4}
|
||||
axisBottom={null}
|
||||
axisLeft={{ tickSize: 0, tickPadding: 8 }}
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
enableLabel={true}
|
||||
label={(d) => formatNumber(d.value as number)}
|
||||
labelSkipWidth={40}
|
||||
labelTextColor="#e8eaed"
|
||||
tooltip={({ indexValue, value }) => (
|
||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||
<p className="text-foreground mb-1 text-xs font-medium">
|
||||
{indexValue}
|
||||
</p>
|
||||
<span className="text-foreground font-mono text-xs font-medium">
|
||||
{(value as number).toLocaleString()} tokens
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
animate={true}
|
||||
motionConfig="gentle"
|
||||
/>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/usage')({
|
||||
component: UsagePage,
|
||||
});
|
||||
@@ -82,12 +82,17 @@ export function safeApiLogValue(value: unknown, maxLength = 50_000): unknown {
|
||||
const sanitized = sanitizeObject(value);
|
||||
|
||||
if (typeof sanitized === 'string' && sanitized.length > maxLength) {
|
||||
return sanitized.substring(0, maxLength) + `... [truncated ${sanitized.length - maxLength} chars]`;
|
||||
return (
|
||||
sanitized.substring(0, maxLength) + `... [truncated ${sanitized.length - maxLength} chars]`
|
||||
);
|
||||
}
|
||||
|
||||
const stringified = JSON.stringify(sanitized);
|
||||
if (stringified.length > maxLength) {
|
||||
return stringified.substring(0, maxLength) + `... [truncated ${stringified.length - maxLength} chars]`;
|
||||
return (
|
||||
stringified.substring(0, maxLength) +
|
||||
`... [truncated ${stringified.length - maxLength} chars]`
|
||||
);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
|
||||
@@ -22,9 +22,13 @@ export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean>
|
||||
setServerConfig(config);
|
||||
|
||||
try {
|
||||
app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ bodyLimit: 10 * 1024 * 1024 }),
|
||||
{
|
||||
logger: ['error', 'warn', 'log'],
|
||||
},
|
||||
);
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors();
|
||||
|
||||