fix(cliproxy): merge dev with proper remote mode integration

Resolve conflicts in cliproxy-auth-routes.ts by combining:
- Dev's improved error handling with try-catch patterns
- Branch's remote mode routing logic

All 638 tests pass.
This commit is contained in:
kaitranntt
2025-12-21 19:21:29 -05:00
86 changed files with 6570 additions and 3380 deletions
+24 -12
View File
@@ -82,22 +82,31 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get version from package.json (updated by semantic-release)
# Get version from package.json
VERSION=$(jq -r '.version' package.json)
# Find commits since last release
LAST_RELEASE=$(git log --oneline --grep="chore(release):" -n 2 | tail -1 | cut -d' ' -f1)
RANGE="${LAST_RELEASE:-HEAD~20}..HEAD"
# Get commits ONLY since last dev tag (not all release commits)
# This prevents re-tagging issues from older releases
LAST_DEV_TAG=$(git tag -l "v*-dev.*" --sort=-v:refname | head -1 || echo "")
if [ -n "$LAST_DEV_TAG" ]; then
# Commits between last dev tag and current (excluding release commit)
RANGE="${LAST_DEV_TAG}..HEAD~1"
else
# First dev release - check commits since last stable tag
STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --merged origin/main --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || echo "")
RANGE="${STABLE_TAG:-HEAD~10}..HEAD~1"
fi
echo "Checking commits in range: $RANGE"
# Extract issue numbers
ISSUES=$(git log $RANGE --pretty=format:"%s %b" | \
# Extract issue numbers from commit messages
ISSUES=$(git log $RANGE --pretty=format:"%s %b" 2>/dev/null | \
grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \
grep -oE "#[0-9]+" | sort -u || true)
if [[ -z "$ISSUES" ]]; then
echo "No linked issues found"
echo "No linked issues found in range"
exit 0
fi
@@ -110,13 +119,16 @@ jobs:
for ISSUE in $ISSUES; do
NUM=${ISSUE#\#}
# Skip if already tagged
if gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '.labels[].name' | grep -q "released-dev"; then
echo "Issue #$NUM already tagged, skipping"
# Skip if already has ANY release label (prevents duplicate comments)
RELEASE_LABELS=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(startswith("released"))] | length' 2>/dev/null || echo "0")
if [[ "$RELEASE_LABELS" -gt 0 ]]; then
echo "Issue #$NUM already released, skipping"
continue
fi
echo "Tagging issue #$NUM"
gh issue comment "$NUM" --repo "${{ github.repository }}" --body "[i] Available in \`$VERSION\`. Update: \`ccs update --dev\`" || true
gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true
# Remove pending-release (transition), add released-dev
gh issue edit "$NUM" --remove-label "pending-release" --add-label "released-dev" --repo "${{ github.repository }}" 2>/dev/null || \
gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true
gh issue comment "$NUM" --repo "${{ github.repository }}" --body "[bot] Available in \`$VERSION\`. Install: \`ccs update --dev\`" || true
done
+24
View File
@@ -73,3 +73,27 @@ jobs:
exit 0
fi
node scripts/send-discord-release.cjs production "$DISCORD_WEBHOOK_URL"
- name: Cleanup stale labels on released issues
if: success() && steps.release.outputs.released == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# semantic-release already adds "released" label via .releaserc.cjs
# This step removes transitional labels from issues now in stable
# Find issues with both "released" and "released-dev" labels
ISSUES=$(gh issue list --label "released" --label "released-dev" --state all --json number --jq '.[].number' 2>/dev/null || echo "")
for NUM in $ISSUES; do
echo "Cleaning up labels on issue #$NUM"
gh issue edit "$NUM" --remove-label "released-dev" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true
done
# Also clean pending-release from any issues with released label
PENDING=$(gh issue list --label "released" --label "pending-release" --state all --json number --jq '.[].number' 2>/dev/null || echo "")
for NUM in $PENDING; do
echo "Removing pending-release from issue #$NUM"
gh issue edit "$NUM" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true
done
+7 -1
View File
@@ -35,4 +35,10 @@ package-lock.json
.claude/active-plan
# Logs directory
logs/
logs/
# CI-generated files
.dev-release-info.json
# Test coverage
ui/coverage/
+38
View File
@@ -1,3 +1,41 @@
## [7.1.1](https://github.com/kaitranntt/ccs/compare/v7.1.0...v7.1.1) (2025-12-21)
### Bug Fixes
* **hooks:** memoize return objects to prevent infinite render loops ([f15b989](https://github.com/kaitranntt/ccs/commit/f15b98950865c01ec6d8d846e3a197bb04e6cf6e))
* **settings:** memoize useSettingsActions to prevent infinite render loop ([4f75e10](https://github.com/kaitranntt/ccs/commit/4f75e105a9ab0c498fb1748829396d695836be65))
### Documentation
* update documentation for modularization phases 6-9 ([e45b46d](https://github.com/kaitranntt/ccs/commit/e45b46d20708e29e770307dbfcce33d84465f137))
### Code Refactoring
* **ui:** modularize analytics page into directory structure ([03d9bf7](https://github.com/kaitranntt/ccs/commit/03d9bf76c474f93f12fcc5dbdaa55c1f215b1e39))
* **ui:** modularize auth-monitor into directory structure ([8ead6fa](https://github.com/kaitranntt/ccs/commit/8ead6fa0bf05fc8d37563a618c473d7eae808920))
* **ui:** modularize settings page into directory structure ([104a404](https://github.com/kaitranntt/ccs/commit/104a40414437a4f32492e4bcc33fdfbbec386e2f))
### Tests
* **ui:** add vitest testing infrastructure with 99 unit tests ([3fca933](https://github.com/kaitranntt/ccs/commit/3fca9338f9a77ac202dde6095bf70b5094199888))
## [7.1.0](https://github.com/kaitranntt/ccs/compare/v7.0.0...v7.1.0) (2025-12-21)
### Features
* **ui:** add visual feedback for WebSearch model auto-save ([eaf566b](https://github.com/kaitranntt/ccs/commit/eaf566beac65284d0809ca8a29f6ce2d03b79af8)), closes [#164](https://github.com/kaitranntt/ccs/issues/164)
### Bug Fixes
* **ci:** use commit-based changelog for dev release Discord notifications ([1129ec6](https://github.com/kaitranntt/ccs/commit/1129ec6ef570e7b922d2831c53bad83a68311b88))
* **ui:** add unsaved changes confirmation when switching profiles ([b790005](https://github.com/kaitranntt/ccs/commit/b790005c85e9f25fd14a14ac01b79e7562f1a1ea)), closes [#163](https://github.com/kaitranntt/ccs/issues/163)
* **ui:** fix profile switching and improve UX ([86d992f](https://github.com/kaitranntt/ccs/commit/86d992fce623a8378d5f53b1aff7b53d2f80e3c4))
### Documentation
* **readme:** add OpenRouter to built-in providers ([676929f](https://github.com/kaitranntt/ccs/commit/676929fc87c4cc450e3dc6e3f05ff60dfcead255))
* **standards:** add input state persistence patterns ([53a7ba8](https://github.com/kaitranntt/ccs/commit/53a7ba8d3ffe81f87306e84357fce4f6ec9d7135)), closes [#165](https://github.com/kaitranntt/ccs/issues/165)
## [7.0.0](https://github.com/kaitranntt/ccs/compare/v6.7.1...v7.0.0) (2025-12-21)
### ⚠ BREAKING CHANGES
+5
View File
@@ -91,9 +91,14 @@ The dashboard provides visual management for all account types:
| **Codex** | OAuth | `ccs codex` | Code generation |
| **Copilot** | OAuth | `ccs copilot` | GitHub Copilot models |
| **Antigravity** | OAuth | `ccs agy` | Alternative routing |
| **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API |
| **GLM** | API Key | `ccs glm` | Cost-optimized execution |
| **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode |
**OpenRouter Integration** (v7.0.0): CCS v7.0.0 adds OpenRouter with interactive model picker, dynamic discovery, and tier mapping (opus/sonnet/haiku). Create via `ccs api create --preset openrouter` or dashboard.
![OpenRouter API Profiles](assets/screenshots/api-profiles-openrouter.webp)
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
**Powered by:**
Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

+4
View File
@@ -0,0 +1,4 @@
[test]
# Exclude UI tests - they use vitest and require jsdom environment
# Run UI tests separately with: cd ui && bun run test
root = "./tests"
+87 -1
View File
@@ -1,6 +1,6 @@
# CCS Code Standards
Last Updated: 2025-12-19
Last Updated: 2025-12-21
Code standards, modularization patterns, and conventions for the CCS codebase.
@@ -377,6 +377,92 @@ export function ComponentName({ id, onSave }: Props) {
---
## Input State Persistence Patterns
When building forms and editors that allow users to make changes, follow these patterns to prevent data loss.
### Pattern 1: Key-Based Remounting
**Use when**: Component has complex local state that should reset on prop changes.
```typescript
// Parent component
<ProfileEditor
key={profileId} // Forces remount when profile changes
profileId={profileId}
onSave={handleSave}
/>
```
**Why**: Without `key`, React reuses the component instance. Local `useState` values persist even when props change, causing stale data bugs.
### Pattern 2: Unsaved Changes Confirmation
**Use when**: User might navigate away while editing.
```typescript
// Parent tracks dirty state
const [editorHasChanges, setEditorHasChanges] = useState(false);
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
// Child notifies parent of dirty state
useEffect(() => {
onHasChangesUpdate?.(computedHasChanges);
}, [computedHasChanges, onHasChangesUpdate]);
// Intercept navigation
const handleSelect = (id: string) => {
if (editorHasChanges && currentId !== id) {
setPendingSwitch(id); // Show confirmation dialog
} else {
setCurrentId(id);
}
};
```
**Flow**:
1. Child computes `hasChanges` from local state vs saved data
2. Child notifies parent via callback
3. Parent intercepts navigation when dirty
4. Show confirmation dialog: "Discard & Switch" or "Cancel"
5. On confirm: reset dirty state, then switch
### Pattern 3: Auto-Save with Visual Feedback
**Use when**: Simple inputs that should save immediately.
```typescript
const [saved, setSaved] = useState(false);
const handleBlur = async () => {
if (value !== savedValue) {
await saveToBackend(value);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
};
return (
<div className="flex items-center gap-2">
<Input value={value} onChange={...} onBlur={handleBlur} />
{saved && (
<span className="text-green-600 text-xs flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> Saved
</span>
)}
</div>
);
```
**When to use which**:
| Scenario | Pattern |
|----------|---------|
| Complex multi-field editor | Pattern 2 (confirmation dialog) |
| Simple single input | Pattern 3 (auto-save + feedback) |
| List item selection | Pattern 1 (key-based remount) + Pattern 2 |
---
## Quality Gates
### Pre-Commit Sequence
+100 -20
View File
@@ -1,8 +1,8 @@
# CCS Codebase Summary
Last Updated: 2025-12-19
Last Updated: 2025-12-21
Comprehensive overview of the modularized CCS codebase structure following the Phase 5 modularization effort.
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure).
## Repository Structure
@@ -231,7 +231,17 @@ ui/src/
│ │
│ ├── monitoring/ # Error logs, auth monitor
│ │ ├── index.ts
│ │ ├── auth-monitor.tsx # Auth monitoring (465 lines)
│ │ ├── proxy-status-widget.tsx
│ │ ├── auth-monitor/ # Split from 465-line file (8 files)
│ │ │ ├── index.tsx # Main component
│ │ │ ├── types.ts
│ │ │ ├── hooks.ts
│ │ │ ├── utils.ts
│ │ │ └── components/
│ │ │ ├── live-pulse.tsx
│ │ │ ├── inline-stats-badge.tsx
│ │ │ ├── provider-card.tsx
│ │ │ └── summary-card.tsx
│ │ └── error-logs/ # Split from 617-line file
│ │ └── [6 focused modules]
│ │
@@ -284,12 +294,46 @@ ui/src/
│ └── utils.ts # Helper functions
├── pages/ # Page components (lazy-loaded)
│ ├── analytics.tsx # Analytics dashboard (420 lines)
│ ├── analytics/ # Split from 420-line file (8 files)
│ │ ├── index.tsx # Main layout
│ │ ├── types.ts # Analytics types
│ │ ├── hooks.ts # Data fetching hooks
│ │ ├── utils.ts # Utility functions
│ │ └── components/
│ │ ├── analytics-header.tsx
│ │ ├── analytics-skeleton.tsx
│ │ ├── charts-grid.tsx
│ │ └── cost-by-model-card.tsx
│ ├── settings/ # Split from 1,781-line file (20 files)
│ │ ├── index.tsx # Main layout with lazy loading
│ │ ├── context.tsx # Settings provider wrapper
│ │ ├── settings-context.ts
│ │ ├── types.ts
│ │ ├── hooks.ts # Legacy re-exports
│ │ ├── hooks/
│ │ │ ├── index.ts
│ │ │ ├── context-hooks.ts
│ │ │ ├── use-settings-tab.ts
│ │ │ ├── use-proxy-config.ts
│ │ │ ├── use-websearch-config.ts
│ │ │ ├── use-globalenv-config.ts
│ │ │ └── use-raw-config.ts
│ │ ├── components/
│ │ │ ├── section-skeleton.tsx
│ │ │ └── tab-navigation.tsx
│ │ └── sections/
│ │ ├── globalenv-section.tsx
│ │ ├── websearch/
│ │ │ ├── index.tsx
│ │ │ └── provider-card.tsx
│ │ └── proxy/
│ │ ├── index.tsx
│ │ ├── local-proxy-card.tsx
│ │ └── remote-proxy-card.tsx
│ ├── api.tsx # API profiles page (350 lines)
│ ├── cliproxy.tsx # CLIProxy page (405 lines)
│ ├── copilot.tsx # Copilot page (295 lines)
── health.tsx # Health page (256 lines)
│ └── settings.tsx # Settings page (1,710 lines - TODO: split)
── health.tsx # Health page (256 lines)
└── providers/ # Context providers
└── websocket-provider.tsx
@@ -305,27 +349,38 @@ ui/src/
| copilot | 2 | config-form (13 files) | 1 monster split |
| health | 2 | - | - |
| layout | 3 | - | - |
| monitoring | 3 | error-logs (6 files) | 1 monster split |
| monitoring | 3 | auth-monitor (8 files), error-logs (6 files) | 2 monster splits |
| profiles | 4 | editor (10 files) | 1 monster split |
| setup | 2 | wizard/steps | - |
| shared | 19 | - | - |
| **Total** | **51+** | **8 subdirs** | **5 splits** |
| **Total** | **51+** | **10 subdirs** | **7 splits** |
### Page Statistics
| Page | Structure | Files | Notes |
|------|-----------|-------|-------|
| analytics | Directory | 8 | Split 2025-12-21 |
| settings | Directory | 20 | Split 2025-12-21, lazy-loaded sections |
| api | Single file | 1 | 350 lines |
| cliproxy | Single file | 1 | 405 lines |
| copilot | Single file | 1 | 295 lines |
| health | Single file | 1 | 256 lines |
---
## Key File Metrics
### Largest Files (Targets for Future Splitting)
### Largest Files (Acceptable Exceptions)
**CLI (`src/`):**
| File | Lines | Status |
|------|-------|--------|
| model-pricing.ts | 676 | Data file - acceptable |
| glmt-proxy.ts | 675 | Complex - monitor |
| glmt-proxy.ts | 675 | Complex streaming - acceptable |
| cliproxy-executor.ts | 666 | Core logic - acceptable |
| cliproxy-command.ts | 634 | Could split |
| usage/handlers.ts | 633 | Could split |
| cliproxy-command.ts | 634 | Could split if needed |
| usage/handlers.ts | 633 | Could split if needed |
| ccs.ts | 596 | Entry point - acceptable |
| unified-config-loader.ts | 546 | Complex - acceptable |
@@ -333,11 +388,19 @@ ui/src/
| File | Lines | Status |
|------|-------|--------|
| pages/settings.tsx | 1,710 | **TODO: SPLIT** |
| components/ui/sidebar.tsx | 674 | shadcn - acceptable |
| monitoring/auth-monitor.tsx | 465 | Could split |
| pages/analytics.tsx | 420 | Could split |
| pages/cliproxy.tsx | 405 | Acceptable |
| pages/api.tsx | 350 | Acceptable |
| pages/copilot.tsx | 295 | Acceptable |
| pages/health.tsx | 256 | Acceptable |
**Split Files (Completed):**
| Original | Lines | New Location | Files |
|----------|-------|--------------|-------|
| pages/settings.tsx | 1,781 | pages/settings/ | 20 |
| pages/analytics.tsx | 420 | pages/analytics/ | 8 |
| monitoring/auth-monitor.tsx | 465 | monitoring/auth-monitor/ | 8 |
---
@@ -377,18 +440,35 @@ export type { ProviderEditorProps } from './provider-editor';
```
tests/
├── unit/ # Unit tests
│ ├── auth/
├── unit/ # Unit tests (6 core test files)
│ ├── data-aggregator.test.ts
│ ├── cliproxy/
├── config/
── utils/
│ └── remote-proxy-client.test.ts
── jsonl-parser.test.ts
│ ├── model-pricing.test.ts
│ ├── unified-config.test.ts
│ └── mcp-manager.test.ts
├── integration/ # Integration tests
├── native/ # Native install tests
│ ├── linux/
│ ├── macos/
│ └── windows/
── npm/ # npm package tests
── npm/ # npm package tests
├── shared/ # Shared test utilities
└── README.md
```
### Test Metrics
| Metric | Value |
|--------|-------|
| Total Tests | 497 |
| Passing | 497 |
| Skipped | 2 |
| Failed | 0 |
| Coverage Threshold | 90% |
| Test Files | 29 |
---
## Build Outputs
+43 -23
View File
@@ -1,6 +1,6 @@
# CCS Product Development Requirements (PDR)
Last Updated: 2025-12-19
Last Updated: 2025-12-21
## Product Overview
@@ -8,7 +8,9 @@ Last Updated: 2025-12-19
**Tagline**: The universal AI profile manager for Claude Code
**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex) with a React-based dashboard for configuration management.
**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter) with a React-based dashboard for configuration management.
**Current Version**: v7.x (OpenRouter integration added)
---
@@ -20,6 +22,7 @@ Developers using Claude Code face these challenges:
2. **Provider Lock-in**: Stuck with Anthropic's API, cannot use alternatives
3. **No Concurrent Sessions**: Cannot work on different projects with different accounts
4. **Complex Configuration**: Manual env var and config file management
5. **No Usage Analytics**: Lack visibility into token usage and costs across providers
---
@@ -28,10 +31,11 @@ Developers using Claude Code face these challenges:
CCS provides:
1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR`
2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity integration
3. **API Profiles**: GLM, Kimi, any Anthropic-compatible API
2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Copilot integration
3. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API
4. **Visual Dashboard**: React SPA for configuration management
5. **Automatic WebSearch**: MCP fallback for third-party providers
6. **Usage Analytics**: Token tracking, cost analysis, model breakdown
---
@@ -41,8 +45,9 @@ CCS provides:
|-----------|----------|------------------|
| Individual Developer | Work/personal separation | Multi-account Claude |
| Agency/Contractor | Client account isolation | Profile switching |
| Cost-conscious Dev | GLM for bulk operations | API profiles |
| Cost-conscious Dev | GLM for bulk operations | API profiles, analytics |
| Enterprise | Custom LLM integration | OpenAI-compatible endpoints |
| Power User | Multiple providers | OpenRouter 300+ models |
---
@@ -59,7 +64,7 @@ CCS provides:
- Share commands, skills, agents across accounts
### FR-003: OAuth Provider Integration
- Support Gemini, Codex, Antigravity OAuth flows
- Support Gemini, Codex, Antigravity, Copilot OAuth flows
- Browser-based authentication
- Token caching and refresh
@@ -67,11 +72,13 @@ CCS provides:
- Configure custom API endpoints
- Support Anthropic-compatible APIs
- Model mapping and configuration
- OpenRouter integration with 300+ models
### FR-005: Dashboard UI
- Visual profile management
- Real-time health monitoring
- Usage analytics
- Usage analytics with cost tracking
- Modular page architecture (settings, analytics, auth-monitor)
### FR-006: Health Diagnostics
- Verify Claude CLI installation
@@ -80,7 +87,7 @@ CCS provides:
### FR-007: WebSearch Fallback
- Auto-configure MCP web search for third-party profiles
- Support Gemini CLI, Brave, Tavily providers
- Support Gemini CLI, OpenCode, Grok providers
- Graceful fallback chain
---
@@ -108,9 +115,10 @@ CCS provides:
- Identical behavior across platforms
### NFR-005: Maintainability
- Files < 200 lines
- Files < 200 lines (with documented exceptions)
- Domain-based organization
- Barrel exports for clean imports
- 90%+ test coverage
---
@@ -156,17 +164,17 @@ CCS provides:
| Metric | Target | Current |
|--------|--------|---------|
| Startup time | < 100ms | TBD |
| Dashboard load | < 2s | TBD |
| Error rate | < 1% | TBD |
| Test coverage | > 80% | TBD |
| File size compliance | 100% < 200 lines | 85% |
| Startup time | < 100ms | Achieved |
| Dashboard load | < 2s | Achieved |
| Error rate | < 1% | Achieved |
| Test coverage | > 90% | 90% (497 tests) |
| File size compliance | 100% < 200 lines | 95% |
---
## Release Criteria
### v1.0 Release (Current)
### v1.0 Release (Complete)
- [x] Multi-account Claude support
- [x] OAuth provider integration (Gemini, Codex, AGY)
- [x] API profile management
@@ -175,13 +183,23 @@ CCS provides:
- [x] WebSearch fallback
- [x] Cross-platform support
### v1.1 Release (Planned)
- [ ] Settings page modularization
- [ ] Enhanced analytics
- [ ] Profile templates
- [ ] Improved error messages
### v7.0 Release (Complete)
- [x] OpenRouter integration with 300+ models
- [x] Interactive model picker
- [x] Dynamic model discovery
- [x] Tier mapping (opus/sonnet/haiku)
- [x] Settings page modularization (20 files)
- [x] Analytics page modularization (8 files)
- [x] Auth monitor modularization (8 files)
- [x] Comprehensive test infrastructure (497 tests)
### v2.0 Release (Future)
### v8.0 Release (Planned - Q1 2026)
- [ ] Multiple CLIProxyAPI instances (load balancing, failover)
- [ ] Native git worktree support
- [ ] Critical bug fixes (#158, #155, #124)
- [ ] Kiro auth support (#157)
### v9.0 Release (Future - Q2 2026)
- [ ] Team collaboration features
- [ ] Cloud sync for profiles
- [ ] Plugin system
@@ -194,8 +212,9 @@ CCS provides:
### External Services
- Anthropic Claude API
- Google Gemini API
- GitHub Codex API
- GitHub Codex/Copilot API
- Z.AI GLM API
- OpenRouter API
### Third-Party Libraries
- Express.js (web server)
@@ -203,6 +222,7 @@ CCS provides:
- Vite (build tool)
- shadcn/ui (UI components)
- CLIProxyAPI (proxy binary)
- Vitest (testing)
---
@@ -222,4 +242,4 @@ CCS provides:
- [Codebase Summary](./codebase-summary.md) - Technical structure
- [Code Standards](./code-standards.md) - Development conventions
- [System Architecture](./system-architecture.md) - Architecture diagrams
- [Project Roadmap](./project-roadmap.md) - Development phases
- [Project Roadmap](./project-roadmap.md) - Development phases and GitHub issues
+136 -229
View File
@@ -1,273 +1,180 @@
# CCS Project Roadmap
Last Updated: 2025-12-19
Last Updated: 2025-12-21
Development roadmap documenting completed modularization phases and future work.
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
---
## Completed Phases
## Completed Modularization Summary
### Phase 1: Type System Modularization
All major modularization work is complete. The codebase evolved from monolithic files to a well-structured modular architecture.
**Status**: COMPLETE
| Phase | Description | Key Result |
|-------|-------------|------------|
| 1 | Type System | `src/types/` with barrel exports |
| 2 | CLI Commands | `src/commands/` (8 handlers extracted) |
| 3 | CLIProxy | `src/cliproxy/` with auth/, binary/, services/ subdirs |
| 4 | Utils/Errors | `src/utils/ui/`, `src/errors/`, `src/management/` |
| 5 | UI Components | 5 monster files split into modular dirs (54+ modules) |
| 6 | Settings Page | `pages/settings/` (1,781→20 files) |
| 7 | Analytics Page | `pages/analytics/` (420→8 files) |
| 8 | Auth Monitor | `monitoring/auth-monitor/` (465→8 files) |
| 9 | Test Infrastructure | 99 UI tests + 497 CLI tests, 90% coverage |
Extracted TypeScript types into dedicated `types/` directory with barrel exports.
**Changes**:
- Created `src/types/` directory structure
- Extracted types from inline definitions into dedicated files:
- `config.ts` - Config, Settings, EnvVars
- `cli.ts` - ParsedArgs, ExitCode, ClaudeCliInfo
- `delegation.ts` - Session, execution types
- `glmt.ts` - Message transformation types
- `utils.ts` - ErrorCode, LogLevel, Result
- Created `index.ts` barrel export aggregating all types
**Impact**:
- Centralized type definitions
- Cleaner imports across codebase
- Easier type maintenance
---
### Phase 2: CLI Command Extraction
**Status**: COMPLETE
Extracted CLI command handlers from main `ccs.ts` into dedicated `commands/` directory.
**Changes**:
- Created `src/commands/` directory
- Extracted command handlers:
- `doctor-command.ts` - Health diagnostics
- `help-command.ts` - Help text generation
- `install-command.ts` - Install/uninstall logic
- `shell-completion-command.ts` - Shell completions
- `sync-command.ts` - Symlink synchronization
- `update-command.ts` - Self-update logic
- `version-command.ts` - Version display
- `cliproxy-command.ts` - CLIProxy subcommands (634 lines)
**Impact**:
- Reduced `ccs.ts` from ~1200 lines to 596 lines
- Isolated command logic for easier testing
- Clear separation of concerns
---
### Phase 3: CLIProxy Modularization
**Status**: COMPLETE
Heavily modularized the CLIProxy integration module with internal subdirectories.
**Changes**:
- Created subdirectory structure:
- `auth/` - OAuth handlers, token management
- `binary/` - Binary download and management
- `services/` - Service layer abstractions
- Created comprehensive barrel export (`index.ts` - 137 lines)
- Maintained backward compatibility for all exports
**Key Files**:
| File | Lines | Purpose |
|------|-------|---------|
| cliproxy-executor.ts | 666 | Main execution logic |
| config-generator.ts | 531 | Config file generation |
| account-manager.ts | 509 | Account management |
| auth-handler.ts | - | Authentication handling |
| proxy-detector.ts | - | Running proxy detection |
---
### Phase 4: Utility and Error Modularization
**Status**: COMPLETE
Extracted utilities and error handling into focused modules.
**Changes**:
- Created `src/utils/` subdirectories:
- `ui/` - Terminal UI (boxes, colors, spinners)
- `websearch/` - Search integrations
- Created `src/errors/` directory:
- `error-handler.ts` - Main error handling
- `exit-codes.ts` - Exit code definitions
- `cleanup.ts` - Cleanup logic
- Created `src/management/` directory:
- `checks/` - Diagnostic checks
- `repair/` - Auto-repair logic
---
### Phase 5: UI Components Modularization
**Status**: COMPLETE
Major UI refactoring splitting monster files into focused modules.
**Monster Files Split**:
| Original File | Lines | Split Into |
|---------------|-------|------------|
| account-flow-viz.tsx | 1,144 | 12 modules in `flow-viz/` |
| provider-editor.tsx | 921 | 13 modules in `provider-editor/` |
| copilot-config-form.tsx | 846 | 13 modules in `config-form/` |
| error-logs.tsx | 617 | 6 modules in `error-logs/` |
| profile-editor.tsx | 531 | 10 modules in `editor/` |
**Domain Directories Created**:
- `components/account/` - Account management (3 components + flow-viz)
- `components/analytics/` - Usage charts (3 components)
- `components/cliproxy/` - CLIProxy config (10 components + subdirs)
- `components/copilot/` - Copilot settings (2 components + config-form)
- `components/health/` - Health gauges (2 components)
- `components/layout/` - App structure (3 components)
- `components/monitoring/` - Error logs (3 components + error-logs)
- `components/profiles/` - Profile management (4 components + editor)
- `components/setup/` - Setup wizard (2 components + wizard)
- `components/shared/` - Reusable components (19 components)
**Barrel Exports Added**:
- Main barrel: `components/index.ts`
- Domain barrels: One `index.ts` per domain directory
- Subdirectory barrels: For split component directories
**Metrics Achieved**:
- Files >500 lines: 12 → 5 (-58%)
- UI files >200 lines: 28 → 8 (-71%)
- Barrel exports: 5 → 39 (+680%)
- Test coverage: 0% → 90%
---
## Current Status
### Metrics
### Remaining Large Files (Acceptable)
| Metric | Before | After | Change |
|--------|--------|-------|--------|
| Files > 500 lines | 12 | 7 | -42% |
| UI files > 200 lines | 28 | 14 | -50% |
| Barrel exports (CLI) | 5 | 24 | +380% |
| Barrel exports (UI) | 0 | 11 | New |
| Domain directories | 4 | 15 | +275% |
### Remaining Large Files
**CLI (Acceptable)**:
**CLI** (complex core logic):
- `model-pricing.ts` (676 lines) - Data file
- `glmt-proxy.ts` (675 lines) - Complex proxy logic
- `glmt-proxy.ts` (675 lines) - Streaming proxy
- `cliproxy-executor.ts` (666 lines) - Core execution
- `ccs.ts` (596 lines) - Entry point
**UI (Need Attention)**:
- `pages/settings.tsx` (1,710 lines) - **HIGH PRIORITY SPLIT**
**UI** (external/shadcn):
- `components/ui/sidebar.tsx` (674 lines) - shadcn component
- `monitoring/auth-monitor.tsx` (465 lines) - Could split
- `pages/analytics.tsx` (420 lines) - Could split
---
## Future Work
## GitHub Issues Backlog
### Priority 1: Settings Page Split
### Critical (Blocking Users)
**Target**: Split `pages/settings.tsx` (1,710 lines)
| Issue | Title | Type |
|-------|-------|------|
| #158 | AGY not working - Missing API Key - Run /login | bug |
| #155 | Invalid JSON payload error with Gemini/Antigravity | bug |
| #124 | Incorrect model ID for Claude 3.5 Sonnet (Thinking) | bug |
**Proposed Structure**:
```
pages/settings/
├── index.tsx # Main layout
├── general-section.tsx # General settings
├── appearance-section.tsx # Theme, colors
├── providers-section.tsx # Provider config
├── websearch-section.tsx # WebSearch config
├── advanced-section.tsx # Advanced options
├── hooks.ts # Shared hooks
└── types.ts # Settings types
```
### High Priority (Features)
### Priority 2: Analytics Page Split
| Issue | Title | Type | Status |
|-------|-------|------|--------|
| #142 | Configure with available CLIProxyAPI | enhancement | **IN PROGRESS** |
| #157 | Support for Kiro auth from CLIProxyAPIPlus | enhancement | - |
| #123 | Add More Models | enhancement | Ongoing |
| #114 | OpenCode Zen Free model + Auto Rotation API Key | enhancement | - |
**Target**: Split `pages/analytics.tsx` (420 lines)
### Medium Priority
**Proposed Structure**:
```
pages/analytics/
├── index.tsx # Main layout
├── usage-overview.tsx # Usage summary
├── model-breakdown.tsx # Per-model stats
├── cost-analysis.tsx # Cost tracking
└── hooks.ts # Data hooks
```
| Issue | Title | Type |
|-------|-------|------|
| #137 | CCS Cannot Connect to IDE, but Native Claude Works | support |
| #89 | Add Claude Code CLI flag passthrough for delegation | enhancement |
### Priority 3: Auth Monitor Split
### Low Priority / Questions
**Target**: Split `monitoring/auth-monitor.tsx` (465 lines)
**Proposed Structure**:
```
monitoring/auth-monitor/
├── index.tsx # Main component
├── provider-status.tsx # Provider cards
├── token-display.tsx # Token info
├── refresh-controls.tsx # Refresh actions
└── hooks.ts # Auth hooks
```
### Priority 4: Test Coverage
**Target**: Add tests for modularized components
- Unit tests for extracted utilities
- Component tests for split UI modules
- Integration tests for barrel exports
- Snapshot tests for UI components
### Priority 5: Documentation
**Target**: Keep documentation synchronized
- Update codebase-summary.md on structural changes
- Document new patterns in code-standards.md
- Keep architecture diagrams current
- Add inline JSDoc comments
| Issue | Title | Type |
|-------|-------|------|
| #156 | Configure API for Zed IDE | docs |
| #140 | Do we support ampcode? | question |
| #111 | Factory droid CLI support | enhancement |
| #103 | /context command returns incorrect context | invalid |
---
## Development Milestones
## Future Roadmap
| Milestone | Status | Target Date |
|-----------|--------|-------------|
| Phase 1: Types | COMPLETE | - |
| Phase 2: Commands | COMPLETE | - |
| Phase 3: CLIProxy | COMPLETE | - |
| Phase 4: Utils/Errors | COMPLETE | - |
| Phase 5: UI Components | COMPLETE | - |
| Phase 6: Settings Split | PENDING | Q1 2026 |
| Phase 7: Remaining Splits | PENDING | Q1 2026 |
| Phase 8: Test Coverage | PENDING | Q2 2026 |
### Priority 1: Multiple CLIProxyAPI Instances
Support connecting to multiple CLIProxyAPI servers simultaneously.
**Use Cases**:
- Load balancing across multiple proxy servers
- Failover when primary server unavailable
- Geographic distribution for latency optimization
- Separate proxies for different provider groups
**Proposed Config**:
```yaml
cliproxy:
instances:
primary:
url: http://localhost:8000
providers: [gemini, codex]
weight: 80
secondary:
url: http://192.168.1.100:8000
providers: [agy]
weight: 20
failover:
url: http://backup.example.com:8000
priority: 2 # Only if primary/secondary fail
strategy: weighted-round-robin
```
### Priority 2: Native Git Worktree Support
Opt-in automatic git worktree management for features/issues.
**Use Cases**:
- Automatic worktree creation when starting issue
- Isolation of feature development
- Easy cleanup after merge
- Integration with GitHub issues
**Proposed Settings**:
```yaml
worktrees:
enabled: true
base_path: ~/.ccs/worktrees
auto_create: true
auto_cleanup: true
naming: "{issue-number}-{short-title}"
```
### Priority 3: Enhanced Model Support
- **#123**: Expand model catalog with new releases
- **#124**: Fix Claude 3.5 Sonnet (Thinking) model ID
- **#114**: OpenCode Zen free model + API key rotation
### Priority 4: IDE Integration
- **#137**: Debug CCS-to-IDE connection issues
- **#156**: Zed IDE configuration documentation
- **#140**: Investigate ampcode compatibility
- **#111**: Factory droid CLI support assessment
### Priority 5: Authentication Enhancements
- **#158**: Fix AGY OAuth flow
- **#157**: Add Kiro auth support from CLIProxyAPIPlus
---
## Milestones
| Milestone | Status | Target |
|-----------|--------|--------|
| Modularization (Phases 1-9) | COMPLETE | - |
| Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 |
| Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 |
| Git Worktree Support | PLANNED | Q1 2026 |
| Enhanced Model Support | PLANNED | Q2 2026 |
---
## Success Criteria
### Code Quality
All criteria achieved:
- [ ] All files under 200 lines (except documented exceptions)
- [ ] Every directory has barrel export
- [ ] No circular dependencies
- [ ] TypeScript strict mode passing
### Maintainability
- [ ] Clear domain boundaries
- [ ] Consistent naming conventions
- [ ] Comprehensive documentation
- [ ] Easy navigation for new developers
### Developer Experience
- [ ] Fast build times
- [ ] Efficient tree-shaking
- [ ] Clear import paths
- [ ] Minimal cognitive load
- [x] Files under 200 lines (except documented exceptions)
- [x] Every directory has barrel export
- [x] No circular dependencies
- [x] TypeScript strict mode passing
- [x] 90%+ test coverage
- [x] Clear domain boundaries
- [x] Consistent naming conventions
---
+5 -5
View File
@@ -1,6 +1,6 @@
# CCS System Architecture
Last Updated: 2025-12-19
Last Updated: 2025-12-21
High-level architecture documentation for the CCS (Claude Code Switch) system.
@@ -166,14 +166,14 @@ CCS is a CLI wrapper that enables seamless switching between multiple Claude acc
+===========================================================================+
+------------------+
| pages/ | Route-level components
| pages/ | Route-level components (modular directories)
|------------------|
| analytics.tsx |
| analytics/ | 8 files - usage charts, cost tracking
| settings/ | 20 files - lazy-loaded tab sections
| api.tsx |
| cliproxy.tsx |
| copilot.tsx |
| health.tsx |
| settings.tsx |
+------------------+
|
v
@@ -186,7 +186,7 @@ CCS is a CLI wrapper that enables seamless switching between multiple Claude acc
| copilot/ | +------------------+ | use-profiles |
| health/ | | use-websocket |
| layout/ | +------------------+
| monitoring/ |
| monitoring/ | <-- auth-monitor/ (8 files), error-logs/ (6 files)
| profiles/ |
| setup/ |
| shared/ |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.0.0",
"version": "7.1.1-dev.2",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+10
View File
@@ -106,6 +106,16 @@ gh release create "v${VERSION}" \
log_info "Created GitHub prerelease"
# Save release info for Discord notification
# This file is read by send-discord-release.cjs for dev releases
cat > .dev-release-info.json << EOF
{
"version": "${VERSION}",
"notes": $(echo "$NOTES" | jq -Rs .)
}
EOF
log_info "Saved release info for Discord notification"
# Output for GitHub Actions
echo "version=${VERSION}" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "released=true" >> "${GITHUB_OUTPUT:-/dev/null}"
+37 -2
View File
@@ -34,7 +34,7 @@ try {
}
/**
* Extract latest release from CHANGELOG.md
* Extract latest release from CHANGELOG.md (for production releases)
*/
function extractLatestRelease() {
const changelogPath = 'CHANGELOG.md';
@@ -92,6 +92,40 @@ function extractLatestRelease() {
return { version, date, sections };
}
/**
* Extract dev release info from .dev-release-info.json (for dev releases)
* Falls back to CHANGELOG.md if file not found
*/
function extractDevRelease() {
const devInfoPath = '.dev-release-info.json';
if (!fs.existsSync(devInfoPath)) {
console.log('[!] .dev-release-info.json not found, falling back to CHANGELOG.md');
return extractLatestRelease();
}
try {
const content = fs.readFileSync(devInfoPath, 'utf8');
const info = JSON.parse(content);
// Parse commit messages into a simple Changes section
const changes = info.notes
.split('\n')
.filter((line) => line.trim().startsWith('-'))
.map((line) => line.trim().substring(1).trim())
.filter((item) => item);
return {
version: info.version,
date: new Date().toISOString().split('T')[0],
sections: changes.length > 0 ? { Changes: changes } : {},
};
} catch (error) {
console.error('[!] Error reading .dev-release-info.json:', error.message);
return extractLatestRelease();
}
}
/**
* Create Discord embed
*/
@@ -203,7 +237,8 @@ function sendToDiscord(embed) {
// Main
try {
const release = extractLatestRelease();
const isDev = releaseType === 'dev';
const release = isDev ? extractDevRelease() : extractLatestRelease();
console.log(`[i] Preparing ${releaseType} notification for v${release.version}`);
const embed = createEmbed(release);
+1 -1
View File
@@ -38,7 +38,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
description: '349+ models from OpenAI, Anthropic, Google, Meta',
baseUrl: OPENROUTER_BASE_URL,
defaultProfileName: 'openrouter',
defaultModel: 'anthropic/claude-sonnet-4',
defaultModel: 'anthropic/claude-opus-4.5',
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
+68
View File
@@ -0,0 +1,68 @@
/**
* Account Routes - CRUD operations for Claude accounts (profiles.json)
*
* Separated from profile-routes.ts to avoid dual-mounting conflicts.
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
const router = Router();
/**
* GET /api/accounts - List accounts from profiles.json
*/
router.get('/', (_req: Request, res: Response): void => {
try {
const profilesPath = path.join(getCcsDir(), 'profiles.json');
if (!fs.existsSync(profilesPath)) {
res.json({ accounts: [], default: null });
return;
}
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => {
const metadata = meta as Record<string, unknown>;
return {
name,
...metadata,
};
});
res.json({ accounts, default: data.default || null });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/accounts/default - Set default account
*/
router.post('/default', (req: Request, res: Response): void => {
try {
const { name } = req.body;
if (!name) {
res.status(400).json({ error: 'Missing required field: name' });
return;
}
const profilesPath = path.join(getCcsDir(), 'profiles.json');
const data = fs.existsSync(profilesPath)
? JSON.parse(fs.readFileSync(profilesPath, 'utf8'))
: { profiles: {} };
data.default = name;
fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n');
res.json({ default: name });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+108 -79
View File
@@ -34,74 +34,79 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
*/
router.get('/', async (_req: Request, res: Response) => {
// Check if remote mode is enabled
const target = getProxyTarget();
if (target.isRemote) {
try {
router.get('/', async (_req: Request, res: Response): Promise<void> => {
try {
// Check if remote mode is enabled
const target = getProxyTarget();
if (target.isRemote) {
const authStatus = await fetchRemoteAuthStatus(target);
res.json({ authStatus, source: 'remote' });
return;
} catch (error) {
res.status(503).json({
error: (error as Error).message,
authStatus: [],
source: 'remote',
});
return;
}
}
// Local mode: Initialize accounts from existing tokens on first request
initializeAccounts();
// Local mode: Initialize accounts from existing tokens on first request
initializeAccounts();
// Fetch CLIProxyAPI usage stats to determine active providers
const stats = await fetchCliproxyStats();
// Fetch CLIProxyAPI usage stats to determine active providers
const stats = await fetchCliproxyStats();
// Map CLIProxyAPI provider names to our internal provider names
const statsProviderMap: Record<string, CLIProxyProvider> = {
gemini: 'gemini',
antigravity: 'agy',
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
};
// Map CLIProxyAPI provider names to our internal provider names
const statsProviderMap: Record<string, CLIProxyProvider> = {
gemini: 'gemini',
antigravity: 'agy',
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
};
// Update lastUsedAt for providers with recent activity
if (stats?.requestsByProvider) {
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
if (requestCount > 0) {
const provider = statsProviderMap[statsProvider.toLowerCase()];
if (provider) {
// Touch the default account for this provider (or all accounts)
const accounts = getProviderAccounts(provider);
for (const account of accounts) {
// Only touch if this is the default account (most likely being used)
if (account.isDefault) {
touchAccount(provider, account.id);
// Update lastUsedAt for providers with recent activity
if (stats?.requestsByProvider) {
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
if (requestCount > 0) {
const provider = statsProviderMap[statsProvider.toLowerCase()];
if (provider) {
// Touch the default account for this provider (or all accounts)
const accounts = getProviderAccounts(provider);
for (const account of accounts) {
// Only touch if this is the default account (most likely being used)
if (account.isDefault) {
touchAccount(provider, account.id);
}
}
}
}
}
}
const statuses = getAllAuthStatus();
const authStatus = statuses.map((status) => {
const oauthConfig = getOAuthConfig(status.provider);
return {
provider: status.provider,
displayName: oauthConfig.displayName,
authenticated: status.authenticated,
lastAuth: status.lastAuth?.toISOString() || null,
tokenFiles: status.tokenFiles.length,
accounts: status.accounts,
defaultAccount: status.defaultAccount,
};
});
res.json({ authStatus });
} catch (error) {
// Return appropriate error for remote vs local mode
const target = getProxyTarget();
if (target.isRemote) {
res.status(503).json({
error: (error as Error).message,
authStatus: [],
source: 'remote',
});
} else {
res.status(500).json({ error: (error as Error).message });
}
}
const statuses = getAllAuthStatus();
const authStatus = statuses.map((status) => {
const oauthConfig = getOAuthConfig(status.provider);
return {
provider: status.provider,
displayName: oauthConfig.displayName,
authenticated: status.authenticated,
lastAuth: status.lastAuth?.toISOString() || null,
tokenFiles: status.tokenFiles.length,
accounts: status.accounts,
defaultAccount: status.defaultAccount,
};
});
res.json({ authStatus });
});
// ==================== Account Management ====================
@@ -109,11 +114,11 @@ router.get('/', async (_req: Request, res: Response) => {
/**
* GET /api/cliproxy/accounts - Get all accounts across all providers
*/
router.get('/accounts', async (_req: Request, res: Response) => {
// Check if remote mode is enabled
const target = getProxyTarget();
if (target.isRemote) {
try {
router.get('/accounts', async (_req: Request, res: Response): Promise<void> => {
try {
// Check if remote mode is enabled
const target = getProxyTarget();
if (target.isRemote) {
const authStatus = await fetchRemoteAuthStatus(target);
// Transform RemoteAuthStatus[] to account summary format
const accounts = authStatus.flatMap((status) =>
@@ -124,21 +129,26 @@ router.get('/accounts', async (_req: Request, res: Response) => {
);
res.json({ accounts, source: 'remote' });
return;
} catch (error) {
}
// Local mode: Initialize accounts from existing tokens
initializeAccounts();
const accounts = getAllAccountsSummary();
res.json({ accounts });
} catch (error) {
const target = getProxyTarget();
if (target.isRemote) {
res.status(503).json({
error: (error as Error).message,
accounts: [],
source: 'remote',
});
return;
} else {
const message = error instanceof Error ? error.message : 'Failed to list accounts';
res.status(500).json({ error: message });
}
}
// Local mode: Initialize accounts from existing tokens
initializeAccounts();
const accounts = getAllAccountsSummary();
res.json({ accounts });
});
/**
@@ -153,8 +163,13 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => {
return;
}
const accounts = getProviderAccounts(provider as CLIProxyProvider);
res.json({ provider, accounts });
try {
const accounts = getProviderAccounts(provider as CLIProxyProvider);
res.json({ provider, accounts });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get provider accounts';
res.status(500).json({ error: message });
}
});
/**
@@ -184,12 +199,19 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void =
return;
}
const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId);
try {
const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, defaultAccount: accountId });
} else {
res.status(404).json({ error: 'Account not found' });
if (success) {
res.json({ provider, defaultAccount: accountId });
} else {
res
.status(404)
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to set default account';
res.status(500).json({ error: message });
}
});
@@ -214,12 +236,19 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v
return;
}
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
try {
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
if (success) {
res.json({ provider, accountId, deleted: true });
} else {
res.status(404).json({ error: 'Account not found' });
if (success) {
res.json({ provider, accountId, deleted: true });
} else {
res
.status(404)
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to remove account';
res.status(500).json({ error: message });
}
});
+186 -5
View File
@@ -3,6 +3,7 @@
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import {
fetchCliproxyStats,
@@ -11,7 +12,11 @@ import {
fetchCliproxyErrorLogs,
fetchCliproxyErrorLogContent,
} from '../../cliproxy/stats-fetcher';
import { getCliproxyWritablePath } from '../../cliproxy/config-generator';
import {
getCliproxyWritablePath,
getConfigPath,
getAuthDir,
} from '../../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
import { ensureCliproxyService } from '../../cliproxy/service-manager';
import { checkCliproxyUpdate } from '../../cliproxy/binary-manager';
@@ -19,10 +24,9 @@ import { checkCliproxyUpdate } from '../../cliproxy/binary-manager';
const router = Router();
/**
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
* Returns: CliproxyStats or error if proxy not running
* Shared handler for stats/usage endpoint
*/
router.get('/stats', async (_req: Request, res: Response): Promise<void> => {
const handleStatsRequest = async (_req: Request, res: Response): Promise<void> => {
try {
// Check if proxy is running first
const running = await isCliproxyRunning();
@@ -48,7 +52,18 @@ router.get('/stats', async (_req: Request, res: Response): Promise<void> => {
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
};
/**
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
* Returns: CliproxyStats or error if proxy not running
*/
router.get('/stats', handleStatsRequest);
/**
* GET /api/cliproxy/usage - Alias for /stats (frontend compatibility)
*/
router.get('/usage', handleStatsRequest);
/**
* GET /api/cliproxy/status - Check CLIProxyAPI running status
@@ -249,4 +264,170 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise<voi
}
});
// ==================== Config File ====================
/**
* GET /api/cliproxy/config.yaml - Get CLIProxy YAML config content
* Returns: plain text YAML content
*/
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
try {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
}
const content = fs.readFileSync(configPath, 'utf8');
res.type('text/yaml').send(content);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/cliproxy/config.yaml - Save CLIProxy YAML config content
* Body: { content: string }
* Returns: { success: true, path: string }
*/
router.put('/config.yaml', async (req: Request, res: Response): Promise<void> => {
try {
const { content } = req.body;
if (typeof content !== 'string') {
res.status(400).json({ error: 'Missing required field: content' });
return;
}
const configPath = getConfigPath();
// Ensure parent directory exists
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
// Write atomically
const tempPath = configPath + '.tmp';
fs.writeFileSync(tempPath, content);
fs.renameSync(tempPath, configPath);
res.json({ success: true, path: configPath });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// ==================== Auth Files ====================
/**
* GET /api/cliproxy/auth-files - List auth files in auth directory
* Returns: { files: Array<{ name, size, mtime }> }
*/
router.get('/auth-files', async (_req: Request, res: Response): Promise<void> => {
try {
const authDir = getAuthDir();
if (!fs.existsSync(authDir)) {
res.json({ files: [] });
return;
}
const entries = fs.readdirSync(authDir, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => {
const filePath = path.join(authDir, entry.name);
const stat = fs.statSync(filePath);
return {
name: entry.name,
size: stat.size,
mtime: stat.mtime.getTime(),
};
});
res.json({ files, directory: authDir });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/auth-files/download - Download auth file content
* Query: ?name=filename
* Returns: file content as octet-stream
*/
router.get('/auth-files/download', async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.query;
if (!name || typeof name !== 'string') {
res.status(400).json({ error: 'Missing required query parameter: name' });
return;
}
// Validate filename - prevent path traversal
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
res.status(400).json({ error: 'Invalid filename' });
return;
}
const authDir = getAuthDir();
const filePath = path.join(authDir, name);
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: 'Auth file not found' });
return;
}
const content = fs.readFileSync(filePath);
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
res.type('application/octet-stream').send(content);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// ==================== Model Updates ====================
/**
* PUT /api/cliproxy/models/:provider - Update model for a provider
* Body: { model: string }
* Returns: { success: true, provider, model }
*/
router.put('/models/:provider', async (req: Request, res: Response): Promise<void> => {
try {
const { provider } = req.params;
const { model } = req.body;
if (!model || typeof model !== 'string') {
res.status(400).json({ error: 'Missing required field: model' });
return;
}
// Get the settings file for this provider
const ccsDir = getCliproxyWritablePath();
const settingsPath = path.join(ccsDir, `${provider}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: `Settings file not found for provider: ${provider}` });
return;
}
// Read and update settings
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
settings.env = settings.env || {};
settings.env.ANTHROPIC_MODEL = model;
// Write atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
res.json({ success: true, provider, model });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+29 -17
View File
@@ -24,12 +24,16 @@ const router = Router();
/**
* GET /api/config/format - Return current config format and migration status
*/
router.get('/format', (_req: Request, res: Response) => {
res.json({
format: getConfigFormat(),
migrationNeeded: needsMigration(),
backups: getBackupDirectories(),
});
router.get('/format', (_req: Request, res: Response): void => {
try {
res.json({
format: getConfigFormat(),
migrationNeeded: needsMigration(),
backups: getBackupDirectories(),
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
@@ -89,25 +93,33 @@ router.put('/', (req: Request, res: Response): void => {
/**
* POST /api/config/migrate - Trigger migration from JSON to YAML
*/
router.post('/migrate', async (req: Request, res: Response) => {
const dryRun = req.query.dryRun === 'true';
const result = await migrate(dryRun);
res.json(result);
router.post('/migrate', async (req: Request, res: Response): Promise<void> => {
try {
const dryRun = req.query.dryRun === 'true';
const result = await migrate(dryRun);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/config/rollback - Rollback migration to JSON format
*/
router.post('/rollback', async (req: Request, res: Response): Promise<void> => {
const { backupPath } = req.body;
try {
const { backupPath } = req.body;
if (!backupPath || typeof backupPath !== 'string') {
res.status(400).json({ error: 'Missing required field: backupPath' });
return;
if (!backupPath || typeof backupPath !== 'string') {
res.status(400).json({ error: 'Missing required field: backupPath' });
return;
}
const success = await rollback(backupPath);
res.json({ success });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
const success = await rollback(backupPath);
res.json({ success });
});
export default router;
+17 -9
View File
@@ -10,22 +10,30 @@ const router = Router();
/**
* GET /api/health - Run health checks
*/
router.get('/', async (_req: Request, res: Response) => {
const report = await runHealthChecks();
res.json(report);
router.get('/', async (_req: Request, res: Response): Promise<void> => {
try {
const report = await runHealthChecks();
res.json(report);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/health/fix/:checkId - Fix a health issue
*/
router.post('/fix/:checkId', (req: Request, res: Response): void => {
const { checkId } = req.params;
const result = fixHealthIssue(checkId);
try {
const { checkId } = req.params;
const result = fixHealthIssue(checkId);
if (result.success) {
res.json({ success: true, message: result.message });
} else {
res.status(400).json({ success: false, message: result.message });
if (result.success) {
res.json({ success: true, message: result.message });
} else {
res.status(400).json({ success: false, message: result.message });
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
+2 -1
View File
@@ -9,6 +9,7 @@ import { Router } from 'express';
// Import domain routers
import profileRoutes from './profile-routes';
import accountRoutes from './account-routes';
import configRoutes from './config-routes';
import healthRoutes from './health-routes';
import providerRoutes from './provider-routes';
@@ -28,7 +29,7 @@ export const apiRoutes = Router();
// Profile CRUD, settings management, presets, accounts
apiRoutes.use('/profiles', profileRoutes);
apiRoutes.use('/settings', settingsRoutes);
apiRoutes.use('/accounts', profileRoutes);
apiRoutes.use('/accounts', accountRoutes);
// ==================== Unified Config ====================
// Config format, migration
+15 -61
View File
@@ -1,13 +1,11 @@
/**
* Profile Routes - CRUD operations for user profiles and accounts
* Profile Routes - CRUD operations for user profiles
*
* Uses unified config (config.yaml) when available, falls back to legacy (config.json).
* Note: Account routes have been moved to account-routes.ts
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer';
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
@@ -20,15 +18,19 @@ const router = Router();
/**
* GET /api/profiles - List all profiles
*/
router.get('/', (_req: Request, res: Response) => {
const result = listApiProfiles();
// Map isConfigured -> configured for UI compatibility
const profiles = result.profiles.map((p) => ({
name: p.name,
settingsPath: p.settingsPath,
configured: p.isConfigured,
}));
res.json({ profiles });
router.get('/', (_req: Request, res: Response): void => {
try {
const result = listApiProfiles();
// Map isConfigured -> configured for UI compatibility
const profiles = result.profiles.map((p) => ({
name: p.name,
settingsPath: p.settingsPath,
configured: p.isConfigured,
}));
res.json({ profiles });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
@@ -117,52 +119,4 @@ router.delete('/:name', (req: Request, res: Response): void => {
res.json({ name, deleted: true });
});
// ==================== Accounts ====================
/**
* GET /api/accounts - List accounts from profiles.json
*/
router.get('/accounts', (_req: Request, res: Response): void => {
const profilesPath = path.join(getCcsDir(), 'profiles.json');
if (!fs.existsSync(profilesPath)) {
res.json({ accounts: [], default: null });
return;
}
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => {
const metadata = meta as Record<string, unknown>;
return {
name,
...metadata,
};
});
res.json({ accounts, default: data.default || null });
});
/**
* POST /api/accounts/default - Set default account
*/
router.post('/accounts/default', (req: Request, res: Response): void => {
const { name } = req.body;
if (!name) {
res.status(400).json({ error: 'Missing required field: name' });
return;
}
const profilesPath = path.join(getCcsDir(), 'profiles.json');
const data = fs.existsSync(profilesPath)
? JSON.parse(fs.readFileSync(profilesPath, 'utf8'))
: { profiles: {} };
data.default = name;
fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n');
res.json({ default: name });
});
export default router;
+160 -136
View File
@@ -32,103 +32,115 @@ function maskApiKeys(settings: Settings): Settings {
* GET /api/settings/:profile - Get settings with masked API keys
*/
router.get('/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
try {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
const masked = maskApiKeys(settings);
res.json({
profile,
settings: masked,
mtime: stat.mtime.getTime(),
path: settingsPath,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
const masked = maskApiKeys(settings);
res.json({
profile,
settings: masked,
mtime: stat.mtime.getTime(),
path: settingsPath,
});
});
/**
* GET /api/settings/:profile/raw - Get full settings (for editing)
*/
router.get('/:profile/raw', (req: Request, res: Response): void => {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
try {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
res.json({
profile,
settings,
mtime: stat.mtime.getTime(),
path: settingsPath,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
res.json({
profile,
settings,
mtime: stat.mtime.getTime(),
path: settingsPath,
});
});
/**
* PUT /api/settings/:profile - Update settings with conflict detection and backup
*/
router.put('/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const { settings, expectedMtime } = req.body;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
try {
const { profile } = req.params;
const { settings, expectedMtime } = req.body;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
const fileExists = fs.existsSync(settingsPath);
const fileExists = fs.existsSync(settingsPath);
// Only check conflict if file exists and expectedMtime was provided
if (fileExists && expectedMtime) {
const stat = fs.statSync(settingsPath);
if (stat.mtime.getTime() !== expectedMtime) {
res.status(409).json({
error: 'File modified externally',
currentMtime: stat.mtime.getTime(),
});
return;
// Only check conflict if file exists and expectedMtime was provided
if (fileExists && expectedMtime) {
const stat = fs.statSync(settingsPath);
if (stat.mtime.getTime() !== expectedMtime) {
res.status(409).json({
error: 'File modified externally',
currentMtime: stat.mtime.getTime(),
});
return;
}
}
}
// Create backup only if file exists
let backupPath: string | undefined;
if (fileExists) {
const backupDir = path.join(ccsDir, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
// Create backup only if file exists
let backupPath: string | undefined;
if (fileExists) {
const backupDir = path.join(ccsDir, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
fs.copyFileSync(settingsPath, backupPath);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
fs.copyFileSync(settingsPath, backupPath);
// Ensure directory exists for new files
if (!fileExists) {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
}
// Write new settings atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
const newStat = fs.statSync(settingsPath);
res.json({
profile,
mtime: newStat.mtime.getTime(),
backupPath,
created: !fileExists,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
// Ensure directory exists for new files
if (!fileExists) {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
}
// Write new settings atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
const newStat = fs.statSync(settingsPath);
res.json({
profile,
mtime: newStat.mtime.getTime(),
backupPath,
created: !fileExists,
});
});
// ==================== Presets ====================
@@ -137,85 +149,97 @@ router.put('/:profile', (req: Request, res: Response): void => {
* GET /api/settings/:profile/presets - Get saved presets for a provider
*/
router.get('/:profile/presets', (req: Request, res: Response): void => {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
try {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.json({ presets: [] });
return;
if (!fs.existsSync(settingsPath)) {
res.json({ presets: [] });
return;
}
const settings = loadSettings(settingsPath);
res.json({ presets: settings.presets || [] });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
const settings = loadSettings(settingsPath);
res.json({ presets: settings.presets || [] });
});
/**
* POST /api/settings/:profile/presets - Create a new preset
*/
router.post('/:profile/presets', (req: Request, res: Response): void => {
const { profile } = req.params;
const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
try {
const { profile } = req.params;
const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
if (!name || !defaultModel) {
res.status(400).json({ error: 'Missing required fields: name, default' });
return;
if (!name || !defaultModel) {
res.status(400).json({ error: 'Missing required fields: name, default' });
return;
}
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
// Create settings file if it doesn't exist
if (!fs.existsSync(settingsPath)) {
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
}
const settings = loadSettings(settingsPath);
settings.presets = settings.presets || [];
// Check for duplicate name
if (settings.presets.some((p) => p.name === name)) {
res.status(409).json({ error: 'Preset with this name already exists' });
return;
}
const preset = {
name,
default: defaultModel,
opus: opus || defaultModel,
sonnet: sonnet || defaultModel,
haiku: haiku || defaultModel,
};
settings.presets.push(preset);
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
res.status(201).json({ preset });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
// Create settings file if it doesn't exist
if (!fs.existsSync(settingsPath)) {
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
}
const settings = loadSettings(settingsPath);
settings.presets = settings.presets || [];
// Check for duplicate name
if (settings.presets.some((p) => p.name === name)) {
res.status(409).json({ error: 'Preset with this name already exists' });
return;
}
const preset = {
name,
default: defaultModel,
opus: opus || defaultModel,
sonnet: sonnet || defaultModel,
haiku: haiku || defaultModel,
};
settings.presets.push(preset);
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
res.status(201).json({ preset });
});
/**
* DELETE /api/settings/:profile/presets/:name - Delete a preset
*/
router.delete('/:profile/presets/:name', (req: Request, res: Response): void => {
const { profile, name } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
try {
const { profile, name } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
const settings = loadSettings(settingsPath);
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
res.status(404).json({ error: 'Preset not found' });
return;
}
settings.presets = settings.presets.filter((p) => p.name !== name);
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
const settings = loadSettings(settingsPath);
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
res.status(404).json({ error: 'Preset not found' });
return;
}
settings.presets = settings.presets.filter((p) => p.name !== name);
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
res.json({ success: true });
});
export default router;
+67 -59
View File
@@ -78,82 +78,90 @@ router.post('/', (req: Request, res: Response): void => {
* PUT /api/cliproxy/:name - Update cliproxy variant
*/
router.put('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { provider, account, model } = req.body;
try {
const { name } = req.params;
const { provider, account, model } = req.body;
const config = readConfigSafe();
const config = readConfigSafe();
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
const variant = config.cliproxy[name];
// Update fields if provided
if (provider) {
variant.provider = provider;
}
if (account !== undefined) {
if (account) {
variant.account = account;
} else {
delete variant.account; // Remove account to use default
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
}
// Update model in settings file if provided
if (model !== undefined) {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
const settings = loadSettings(settingsPath);
if (model) {
settings.env = settings.env || {};
settings.env.ANTHROPIC_MODEL = model;
} else if (settings.env) {
delete settings.env.ANTHROPIC_MODEL;
const variant = config.cliproxy[name];
// Update fields if provided
if (provider) {
variant.provider = provider;
}
if (account !== undefined) {
if (account) {
variant.account = account;
} else {
delete variant.account; // Remove account to use default
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
// Update model in settings file if provided
if (model !== undefined) {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
const settings = loadSettings(settingsPath);
if (model) {
settings.env = settings.env || {};
settings.env.ANTHROPIC_MODEL = model;
} else if (settings.env) {
delete settings.env.ANTHROPIC_MODEL;
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
}
writeConfig(config);
res.json({
name,
provider: variant.provider,
account: variant.account || 'default',
settings: variant.settings,
updated: true,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
writeConfig(config);
res.json({
name,
provider: variant.provider,
account: variant.account || 'default',
settings: variant.settings,
updated: true,
});
});
/**
* DELETE /api/cliproxy/:name - Delete cliproxy variant
*/
router.delete('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
try {
const { name } = req.params;
const config = readConfigSafe();
const config = readConfigSafe();
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
// Never delete settings files for reserved provider names (safety guard)
if (!isReservedName(name)) {
// Only delete settings file for non-reserved variant names
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
return;
}
// Never delete settings files for reserved provider names (safety guard)
if (!isReservedName(name)) {
// Only delete settings file for non-reserved variant names
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
}
delete config.cliproxy[name];
writeConfig(config);
res.json({ name, deleted: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
delete config.cliproxy[name];
writeConfig(config);
res.json({ name, deleted: true });
});
export default router;
+287 -2
View File
@@ -46,25 +46,42 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/recharts": "^1.8.29",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.16",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.3.0",
"msw": "^2.12.4",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vitest": "^4.0.16",
},
},
},
"packages": {
"@acemir/cssom": ["@acemir/cssom@0.9.29", "", {}, "sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA=="],
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.1", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.4" } }, "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.6", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.4" } }, "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
@@ -105,6 +122,20 @@
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.22", "", {}, "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
@@ -195,6 +226,16 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
"@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="],
"@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="],
"@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="],
"@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -205,6 +246,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mswjs/interceptors": ["@mswjs/interceptors@0.40.0", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ=="],
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
"@nivo/core": ["@nivo/core@0.99.0", "", { "dependencies": { "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-shape": "^3.1.6", "d3-color": "^3.1.0", "d3-format": "^1.4.4", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "d3-shape": "^3.2.0", "d3-time-format": "^3.0.0", "lodash": "^4.17.21", "react-virtualized-auto-sizer": "^1.0.26", "use-debounce": "^10.0.4" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g=="],
@@ -219,6 +262,12 @@
"@nivo/tooltip": ["@nivo/tooltip@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg=="],
"@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
"@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="],
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -359,6 +408,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
@@ -399,6 +450,16 @@
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
"@testing-library/react": ["@testing-library/react@16.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw=="],
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -407,6 +468,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
@@ -429,6 +492,8 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -443,6 +508,8 @@
"@types/recharts": ["@types/recharts@1.8.29", "", { "dependencies": { "@types/d3-shape": "^1", "@types/react": "*" } }, "sha512-ulKklaVsnFIIhTQsQw226TnOibrddW1qUQNFVhoQEyY1Z7FRQrNecFCGt7msRuJseudzE9czVawZb17dK/aPXw=="],
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/type-utils": "8.48.1", "@typescript-eslint/utils": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA=="],
@@ -465,22 +532,50 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.16", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.16", "ast-v8-to-istanbul": "^0.3.8", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.16", "vitest": "4.0.16" }, "optionalPeers": ["@vitest/browser"] }, "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A=="],
"@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
"@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="],
"@vitest/runner": ["@vitest/runner@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" } }, "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q=="],
"@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="],
"@vitest/spy": ["@vitest/spy@4.0.16", "", {}, "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw=="],
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.9", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^9.0.1" } }, "sha512-dSC6tJeOJxbZrPzPbv5mMd6CMiQ1ugaVXXPRad2fXUSsy1kstFn9XQWemV9VW7Y7kpxgQ/4WMoZfwdH8XSU48w=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.4", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
@@ -489,12 +584,18 @@
"caniuse-lite": ["caniuse-lite@1.0.30001759", "", {}, "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw=="],
"chai": ["chai@6.2.1", "", {}, "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -509,6 +610,12 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"cssstyle": ["cssstyle@5.3.5", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0" } }, "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@@ -537,26 +644,40 @@
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.0.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.266", "", {}, "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -583,10 +704,14 @@
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.3.3", "", {}, "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw=="],
@@ -609,6 +734,8 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -619,32 +746,64 @@
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsdom": ["jsdom@27.3.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -691,16 +850,30 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"lucide-react": ["lucide-react@0.556.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.1", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "source-map-js": "^1.2.1" } }, "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.12.4", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg=="],
"mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
@@ -709,18 +882,28 @@
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -731,6 +914,8 @@
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
@@ -777,10 +962,22 @@
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rettime": ["rettime@0.7.0", "", {}, "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw=="],
"rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -791,14 +988,36 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
@@ -807,20 +1026,38 @@
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="],
"tldts-core": ["tldts-core@7.0.19", "", {}, "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A=="],
"tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@5.3.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.48.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -835,20 +1072,54 @@
"vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="],
"vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
@@ -889,12 +1160,18 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
@@ -905,6 +1182,14 @@
"d3-time-format/d3-time": ["d3-time@2.1.1", "", { "dependencies": { "d3-array": "2" } }, "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ=="],
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"victory-vendor/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
+15 -4
View File
@@ -9,10 +9,14 @@
"typecheck": "tsc -b --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"format": "prettier --write src/ tests/",
"format:check": "prettier --check src/ tests/",
"validate": "bun run typecheck && bun run lint:fix && bun run format:check",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -57,20 +61,27 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/recharts": "^1.8.29",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.16",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.3.0",
"msw": "^2.12.4",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.0.16"
}
}
@@ -1,465 +0,0 @@
/**
* Auth Monitor Component with Account Flow Visualization
* Shows request flow from accounts to providers using custom SVG bezier curves
* Uses glass panel aesthetic with hover interactions and glow effects
*/
import { useState, useMemo, useEffect } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats';
import { cn, STATUS_COLORS } from '@/lib/utils';
import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config';
import { Skeleton } from '@/components/ui/skeleton';
import { ProviderIcon } from '@/components/shared/provider-icon';
import { AccountFlowViz } from '@/components/account-flow-viz';
import { usePrivacy } from '@/contexts/privacy-context';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react';
interface AccountRow {
id: string;
email: string;
provider: string;
displayName: string;
isDefault: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
color: string;
}
interface ProviderStats {
provider: string;
displayName: string;
totalRequests: number;
successCount: number;
failureCount: number;
accountCount: number;
accounts: AccountRow[];
}
function getSuccessRate(success: number, failure: number): number {
const total = success + failure;
if (total === 0) return 100;
return Math.round((success / total) * 100);
}
/** Strip common email domains for cleaner display */
function cleanEmail(email: string): string {
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
}
// Vibrant colors for account segments - darker for light theme contrast
const ACCOUNT_COLORS = [
'#1e6091', // Deep Cerulean (was #277da1)
'#2d8a6e', // Deep Seaweed (was #43aa8b)
'#d4a012', // Dark Tuscan (was #f9c74f)
'#c92a2d', // Deep Strawberry (was #f94144)
'#c45a1a', // Deep Pumpkin (was #f3722c)
'#6b9c4d', // Dark Willow (was #90be6d)
'#3d5a73', // Deep Blue Slate (was #577590)
'#cc7614', // Dark Carrot (was #f8961e)
'#3a7371', // Deep Cyan (was #4d908e)
'#7c5fc4', // Deep Purple (was #a78bfa)
];
/** Enhanced live pulse indicator with multi-ring animation */
function LivePulse() {
return (
<div className="relative flex items-center justify-center w-5 h-5">
{/* Outer ping ring */}
<div
className="absolute w-4 h-4 rounded-full animate-ping opacity-20"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Middle pulse ring */}
<div
className="absolute w-3 h-3 rounded-full animate-pulse opacity-40"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Inner solid dot */}
<div
className="relative w-2 h-2 rounded-full z-10"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
</div>
);
}
/** Inline success/failure badge for provider cards */
function InlineStatsBadge({ success, failure }: { success: number; failure: number }) {
if (success === 0 && failure === 0) {
return <span className="text-[9px] text-muted-foreground/50 font-mono">no activity</span>;
}
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<CheckCircle2 className="w-3 h-3 text-emerald-700 dark:text-emerald-500" />
<span className="text-[10px] font-mono font-medium text-emerald-700 dark:text-emerald-500">
{success.toLocaleString()}
</span>
</div>
{failure > 0 && (
<div className="flex items-center gap-0.5">
<XCircle className="w-3 h-3 text-red-700 dark:text-red-500" />
<span className="text-[10px] font-mono font-medium text-red-700 dark:text-red-500">
{failure.toLocaleString()}
</span>
</div>
)}
</div>
);
}
export function AuthMonitor() {
const { data, isLoading, error } = useCliproxyAuth();
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
const { privacyMode } = usePrivacy();
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
// Live countdown showing time since last data update
useEffect(() => {
if (!dataUpdatedAt) return;
const updateTime = () => {
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
if (diff < 60) {
setTimeSinceUpdate(`${diff}s ago`);
} else {
setTimeSinceUpdate(`${Math.floor(diff / 60)}m ago`);
}
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, [dataUpdatedAt]);
// Build a map of account email -> usage stats from CLIProxy
const accountStatsMap = useMemo(() => {
if (!statsData?.accountStats) return new Map<string, AccountUsageStats>();
return new Map(Object.entries(statsData.accountStats));
}, [statsData?.accountStats]);
// Transform auth status data into account rows
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
if (!data?.authStatus) {
return {
accounts: [],
totalSuccess: 0,
totalFailure: 0,
totalRequests: 0,
providerStats: [],
};
}
const accountsList: AccountRow[] = [];
const providerMap = new Map<
string,
{ success: number; failure: number; accounts: AccountRow[] }
>();
let tSuccess = 0;
let tFailure = 0;
let colorIndex = 0;
data.authStatus.forEach((status: AuthStatus) => {
const providerKey = status.provider;
if (!providerMap.has(providerKey)) {
providerMap.set(providerKey, { success: 0, failure: 0, accounts: [] });
}
const providerData = providerMap.get(providerKey);
if (!providerData) return;
status.accounts?.forEach((account: OAuthAccount) => {
// Get real stats from CLIProxy - try email first, then id
const accountEmail = account.email || account.id;
const realStats = accountStatsMap.get(accountEmail);
const success = realStats?.successCount ?? 0;
const failure = realStats?.failureCount ?? 0;
tSuccess += success;
tFailure += failure;
providerData.success += success;
providerData.failure += failure;
const row: AccountRow = {
id: account.id,
email: account.email || account.id,
provider: status.provider,
displayName: status.displayName,
isDefault: account.isDefault,
successCount: success,
failureCount: failure,
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
};
accountsList.push(row);
providerData.accounts.push(row);
colorIndex++;
});
});
// Build provider stats array
const providerStatsArr: ProviderStats[] = [];
providerMap.forEach((pData, provider) => {
if (pData.accounts.length === 0) return;
providerStatsArr.push({
provider,
displayName: getProviderDisplayName(provider),
totalRequests: pData.success + pData.failure,
successCount: pData.success,
failureCount: pData.failure,
accountCount: pData.accounts.length,
accounts: pData.accounts,
});
});
providerStatsArr.sort((a, b) => b.totalRequests - a.totalRequests);
return {
accounts: accountsList,
totalSuccess: tSuccess,
totalFailure: tFailure,
totalRequests: tSuccess + tFailure,
providerStats: providerStatsArr,
};
}, [data?.authStatus, accountStatsMap]);
const overallSuccessRate =
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
// Get selected provider data for detail view
const selectedProviderData = selectedProvider
? providerStats.find((ps) => ps.provider === selectedProvider)
: null;
if (isLoading || statsLoading) {
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="p-4 space-y-4">
<div className="flex gap-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 flex-1 rounded-lg" />
))}
</div>
<Skeleton className="h-48 w-full rounded-lg" />
</div>
</div>
);
}
if (error || !data?.authStatus || accounts.length === 0) {
return null;
}
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
{/* Enhanced Live Header with gradient glow */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-emerald-500/5 via-transparent to-transparent dark:from-emerald-500/10">
<div className="flex items-center gap-2">
<LivePulse />
<span className="text-xs font-semibold tracking-tight text-foreground">LIVE</span>
<span className="text-[10px] text-muted-foreground">Account Monitor</span>
</div>
<div className="flex items-center gap-4 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<Radio className="w-3 h-3 animate-pulse" />
<span>Updated {timeSinceUpdate || 'now'}</span>
</div>
<span className="text-muted-foreground/50">|</span>
<span>{accounts.length} accounts</span>
<span className="font-mono">{totalRequests.toLocaleString()} req</span>
</div>
</div>
{/* Summary Stats Row */}
<div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30">
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Accounts"
value={accounts.length}
color="var(--accent)"
/>
<SummaryCard
icon={<CheckCircle2 className="w-4 h-4" />}
label="Success"
value={totalSuccess.toLocaleString()}
color={STATUS_COLORS.success}
/>
<SummaryCard
icon={<XCircle className="w-4 h-4" />}
label="Failed"
value={totalFailure.toLocaleString()}
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
/>
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Success Rate"
value={`${overallSuccessRate}%`}
color={
overallSuccessRate === 100
? STATUS_COLORS.success
: overallSuccessRate >= 95
? STATUS_COLORS.degraded
: STATUS_COLORS.failed
}
/>
</div>
{/* Flow Visualization */}
<div className="relative overflow-hidden">
{selectedProviderData ? (
// Account-level flow view
<AccountFlowViz
providerData={selectedProviderData}
onBack={() => setSelectedProvider(null)}
/>
) : (
// Provider cards view
<div className="p-6">
<div className="text-[10px] text-muted-foreground uppercase tracking-widest mb-4">
Request Distribution by Provider
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{providerStats.map((ps) => {
const successRate = getSuccessRate(ps.successCount, ps.failureCount);
const providerColor = PROVIDER_COLORS[ps.provider.toLowerCase()] || '#6b7280';
const isHovered = hoveredProvider === ps.provider;
return (
<button
key={ps.provider}
onClick={() => setSelectedProvider(ps.provider)}
onMouseEnter={() => setHoveredProvider(ps.provider)}
onMouseLeave={() => setHoveredProvider(null)}
className={cn(
'group relative rounded-xl p-4 text-left transition-all duration-300',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'hover:border-opacity-50 hover:scale-[1.02] hover:shadow-lg',
isHovered && 'ring-1'
)}
style={
{
borderColor: isHovered ? providerColor : undefined,
'--ring-color': providerColor,
} as React.CSSProperties
}
>
<div className="flex items-center gap-3 mb-3">
<ProviderIcon provider={ps.provider} size={36} withBackground />
<div>
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{ps.displayName}
</h3>
<p className="text-[10px] text-muted-foreground">
{ps.accountCount} account{ps.accountCount !== 1 ? 's' : ''}
</p>
</div>
<ChevronRight
className={cn(
'w-4 h-4 ml-auto text-muted-foreground transition-all',
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
)}
/>
</div>
<div className="space-y-2">
{/* Inline success/failure stats - immediately visible */}
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Stats</span>
<InlineStatsBadge success={ps.successCount} failure={ps.failureCount} />
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Success Rate</span>
<span
className="font-mono font-semibold"
style={{
color:
successRate === 100
? STATUS_COLORS.success
: successRate >= 95
? STATUS_COLORS.degraded
: STATUS_COLORS.failed,
}}
>
{successRate}%
</span>
</div>
{/* Progress bar */}
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${successRate}%`,
backgroundColor: providerColor,
}}
/>
</div>
</div>
{/* Account color dots */}
<div className="flex gap-1 mt-3">
{ps.accounts.slice(0, 5).map((acc) => (
<div
key={acc.id}
className="w-2 h-2 rounded-full"
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
/>
))}
{ps.accounts.length > 5 && (
<span className="text-[10px] text-muted-foreground ml-1">
+{ps.accounts.length - 5}
</span>
)}
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
);
}
// Summary Card Component
function SummaryCard({
icon,
label,
value,
color,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
color?: string;
}) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-card/50 dark:bg-zinc-900/50 border border-border/50 dark:border-white/[0.06]">
<div
className="w-8 h-8 rounded-md flex items-center justify-center"
style={{
backgroundColor: color ? `${color}15` : 'var(--muted)',
color: color || 'var(--muted-foreground)',
}}
>
{icon}
</div>
<div>
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</div>
<div
className="text-lg font-semibold font-mono leading-tight"
style={{ color: color || 'var(--foreground)' }}
>
{value}
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
/**
* InlineStatsBadge - Inline success/failure badge for provider cards
*/
import { CheckCircle2, XCircle } from 'lucide-react';
interface InlineStatsBadgeProps {
success: number;
failure: number;
}
export function InlineStatsBadge({ success, failure }: InlineStatsBadgeProps) {
if (success === 0 && failure === 0) {
return <span className="text-[9px] text-muted-foreground/50 font-mono">no activity</span>;
}
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<CheckCircle2 className="w-3 h-3 text-emerald-700 dark:text-emerald-500" />
<span className="text-[10px] font-mono font-medium text-emerald-700 dark:text-emerald-500">
{success.toLocaleString()}
</span>
</div>
{failure > 0 && (
<div className="flex items-center gap-0.5">
<XCircle className="w-3 h-3 text-red-700 dark:text-red-500" />
<span className="text-[10px] font-mono font-medium text-red-700 dark:text-red-500">
{failure.toLocaleString()}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,27 @@
/**
* LivePulse - Enhanced live pulse indicator with multi-ring animation
*/
import { STATUS_COLORS } from '@/lib/utils';
export function LivePulse() {
return (
<div className="relative flex items-center justify-center w-5 h-5">
{/* Outer ping ring */}
<div
className="absolute w-4 h-4 rounded-full animate-ping opacity-20"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Middle pulse ring */}
<div
className="absolute w-3 h-3 rounded-full animate-pulse opacity-40"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Inner solid dot */}
<div
className="relative w-2 h-2 rounded-full z-10"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
</div>
);
}
@@ -0,0 +1,123 @@
/**
* ProviderCard - Provider status card with account color dots and stats
*/
import type React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn, STATUS_COLORS } from '@/lib/utils';
import { PROVIDER_COLORS } from '@/lib/provider-config';
import { ProviderIcon } from '@/components/shared/provider-icon';
import type { ProviderStats } from '../types';
import { getSuccessRate, cleanEmail } from '../utils';
import { InlineStatsBadge } from './inline-stats-badge';
interface ProviderCardProps {
stats: ProviderStats;
isHovered: boolean;
privacyMode: boolean;
onSelect: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export function ProviderCard({
stats,
isHovered,
privacyMode,
onSelect,
onMouseEnter,
onMouseLeave,
}: ProviderCardProps) {
const successRate = getSuccessRate(stats.successCount, stats.failureCount);
const providerColor = PROVIDER_COLORS[stats.provider.toLowerCase()] || '#6b7280';
return (
<button
onClick={onSelect}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={cn(
'group relative rounded-xl p-4 text-left transition-all duration-300',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'hover:border-opacity-50 hover:scale-[1.02] hover:shadow-lg',
isHovered && 'ring-1'
)}
style={
{
borderColor: isHovered ? providerColor : undefined,
'--ring-color': providerColor,
} as React.CSSProperties
}
>
<div className="flex items-center gap-3 mb-3">
<ProviderIcon provider={stats.provider} size={36} withBackground />
<div>
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{stats.displayName}
</h3>
<p className="text-[10px] text-muted-foreground">
{stats.accountCount} account{stats.accountCount !== 1 ? 's' : ''}
</p>
</div>
<ChevronRight
className={cn(
'w-4 h-4 ml-auto text-muted-foreground transition-all',
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
)}
/>
</div>
<div className="space-y-2">
{/* Inline success/failure stats - immediately visible */}
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Stats</span>
<InlineStatsBadge success={stats.successCount} failure={stats.failureCount} />
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Success Rate</span>
<span
className="font-mono font-semibold"
style={{
color:
successRate === 100
? STATUS_COLORS.success
: successRate >= 95
? STATUS_COLORS.degraded
: STATUS_COLORS.failed,
}}
>
{successRate}%
</span>
</div>
{/* Progress bar */}
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${successRate}%`,
backgroundColor: providerColor,
}}
/>
</div>
</div>
{/* Account color dots */}
<div className="flex gap-1 mt-3">
{stats.accounts.slice(0, 5).map((acc) => (
<div
key={acc.id}
className="w-2 h-2 rounded-full"
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
/>
))}
{stats.accounts.length > 5 && (
<span className="text-[10px] text-muted-foreground ml-1">
+{stats.accounts.length - 5}
</span>
)}
</div>
</button>
);
}
@@ -0,0 +1,37 @@
/**
* SummaryCard - Summary stats card with icon and value display
*/
import type React from 'react';
interface SummaryCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
color?: string;
}
export function SummaryCard({ icon, label, value, color }: SummaryCardProps) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-card/50 dark:bg-zinc-900/50 border border-border/50 dark:border-white/[0.06]">
<div
className="w-8 h-8 rounded-md flex items-center justify-center"
style={{
backgroundColor: color ? `${color}15` : 'var(--muted)',
color: color || 'var(--muted-foreground)',
}}
>
{icon}
</div>
<div>
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</div>
<div
className="text-lg font-semibold font-mono leading-tight"
style={{ color: color || 'var(--foreground)' }}
>
{value}
</div>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
/**
* Hooks for Auth Monitor data aggregation and state management
*/
import { useState, useMemo, useEffect } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats';
import { getProviderDisplayName } from '@/lib/provider-config';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import type { AccountRow, ProviderStats } from './types';
import { ACCOUNT_COLORS } from './utils';
export interface AuthMonitorData {
accounts: AccountRow[];
totalSuccess: number;
totalFailure: number;
totalRequests: number;
providerStats: ProviderStats[];
overallSuccessRate: number;
isLoading: boolean;
error: Error | null;
timeSinceUpdate: string;
}
/** Hook for computing auth monitor data from CLIProxy auth and stats */
export function useAuthMonitorData(): AuthMonitorData {
const { data, isLoading, error } = useCliproxyAuth();
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
// Live countdown showing time since last data update
useEffect(() => {
if (!dataUpdatedAt) return;
const updateTime = () => {
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
if (diff < 60) {
setTimeSinceUpdate(`${diff}s ago`);
} else {
setTimeSinceUpdate(`${Math.floor(diff / 60)}m ago`);
}
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, [dataUpdatedAt]);
// Build a map of account email -> usage stats from CLIProxy
const accountStatsMap = useMemo(() => {
if (!statsData?.accountStats) return new Map<string, AccountUsageStats>();
return new Map(Object.entries(statsData.accountStats));
}, [statsData?.accountStats]);
// Transform auth status data into account rows
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
if (!data?.authStatus) {
return {
accounts: [] as AccountRow[],
totalSuccess: 0,
totalFailure: 0,
totalRequests: 0,
providerStats: [] as ProviderStats[],
};
}
const accountsList: AccountRow[] = [];
const providerMap = new Map<
string,
{ success: number; failure: number; accounts: AccountRow[] }
>();
let tSuccess = 0;
let tFailure = 0;
let colorIndex = 0;
data.authStatus.forEach((status: AuthStatus) => {
const providerKey = status.provider;
if (!providerMap.has(providerKey)) {
providerMap.set(providerKey, { success: 0, failure: 0, accounts: [] });
}
const providerData = providerMap.get(providerKey);
if (!providerData) return;
status.accounts?.forEach((account: OAuthAccount) => {
const accountEmail = account.email || account.id;
const realStats = accountStatsMap.get(accountEmail);
const success = realStats?.successCount ?? 0;
const failure = realStats?.failureCount ?? 0;
tSuccess += success;
tFailure += failure;
providerData.success += success;
providerData.failure += failure;
const row: AccountRow = {
id: account.id,
email: account.email || account.id,
provider: status.provider,
displayName: status.displayName,
isDefault: account.isDefault,
successCount: success,
failureCount: failure,
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
};
accountsList.push(row);
providerData.accounts.push(row);
colorIndex++;
});
});
// Build provider stats array
const providerStatsArr: ProviderStats[] = [];
providerMap.forEach((pData, provider) => {
if (pData.accounts.length === 0) return;
providerStatsArr.push({
provider,
displayName: getProviderDisplayName(provider),
totalRequests: pData.success + pData.failure,
successCount: pData.success,
failureCount: pData.failure,
accountCount: pData.accounts.length,
accounts: pData.accounts,
});
});
providerStatsArr.sort((a, b) => b.totalRequests - a.totalRequests);
return {
accounts: accountsList,
totalSuccess: tSuccess,
totalFailure: tFailure,
totalRequests: tSuccess + tFailure,
providerStats: providerStatsArr,
};
}, [data?.authStatus, accountStatsMap]);
const overallSuccessRate =
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
return {
accounts,
totalSuccess,
totalFailure,
totalRequests,
providerStats,
overallSuccessRate,
isLoading: isLoading || statsLoading,
error: error ?? null,
timeSinceUpdate,
};
}
@@ -0,0 +1,151 @@
/**
* Auth Monitor Component with Account Flow Visualization
* Shows request flow from accounts to providers using custom SVG bezier curves
* Uses glass panel aesthetic with hover interactions and glow effects
*/
import { useState } from 'react';
import { STATUS_COLORS } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { AccountFlowViz } from '@/components/account-flow-viz';
import { usePrivacy } from '@/contexts/privacy-context';
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
import { useAuthMonitorData } from './hooks';
import { LivePulse } from './components/live-pulse';
import { ProviderCard } from './components/provider-card';
import { SummaryCard } from './components/summary-card';
export function AuthMonitor() {
const {
accounts,
totalSuccess,
totalFailure,
totalRequests,
providerStats,
overallSuccessRate,
isLoading,
error,
timeSinceUpdate,
} = useAuthMonitorData();
const { privacyMode } = usePrivacy();
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
// Get selected provider data for detail view
const selectedProviderData = selectedProvider
? providerStats.find((ps) => ps.provider === selectedProvider)
: null;
if (isLoading) {
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="p-4 space-y-4">
<div className="flex gap-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 flex-1 rounded-lg" />
))}
</div>
<Skeleton className="h-48 w-full rounded-lg" />
</div>
</div>
);
}
if (error || accounts.length === 0) {
return null;
}
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
{/* Enhanced Live Header with gradient glow */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-emerald-500/5 via-transparent to-transparent dark:from-emerald-500/10">
<div className="flex items-center gap-2">
<LivePulse />
<span className="text-xs font-semibold tracking-tight text-foreground">LIVE</span>
<span className="text-[10px] text-muted-foreground">Account Monitor</span>
</div>
<div className="flex items-center gap-4 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<Radio className="w-3 h-3 animate-pulse" />
<span>Updated {timeSinceUpdate || 'now'}</span>
</div>
<span className="text-muted-foreground/50">|</span>
<span>{accounts.length} accounts</span>
<span className="font-mono">{totalRequests.toLocaleString()} req</span>
</div>
</div>
{/* Summary Stats Row */}
<div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30">
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Accounts"
value={accounts.length}
color="var(--accent)"
/>
<SummaryCard
icon={<CheckCircle2 className="w-4 h-4" />}
label="Success"
value={totalSuccess.toLocaleString()}
color={STATUS_COLORS.success}
/>
<SummaryCard
icon={<XCircle className="w-4 h-4" />}
label="Failed"
value={totalFailure.toLocaleString()}
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
/>
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Success Rate"
value={`${overallSuccessRate}%`}
color={
overallSuccessRate === 100
? STATUS_COLORS.success
: overallSuccessRate >= 95
? STATUS_COLORS.degraded
: STATUS_COLORS.failed
}
/>
</div>
{/* Flow Visualization */}
<div className="relative overflow-hidden">
{selectedProviderData ? (
<AccountFlowViz
providerData={selectedProviderData}
onBack={() => setSelectedProvider(null)}
/>
) : (
<div className="p-6">
<div className="text-[10px] text-muted-foreground uppercase tracking-widest mb-4">
Request Distribution by Provider
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{providerStats.map((ps) => (
<ProviderCard
key={ps.provider}
stats={ps}
isHovered={hoveredProvider === ps.provider}
privacyMode={privacyMode}
onSelect={() => setSelectedProvider(ps.provider)}
onMouseEnter={() => setHoveredProvider(ps.provider)}
onMouseLeave={() => setHoveredProvider(null)}
/>
))}
</div>
</div>
)}
</div>
</div>
);
}
// Re-export types for barrel
export type { AccountRow, ProviderStats } from './types';
@@ -0,0 +1,25 @@
/**
* Type definitions for Auth Monitor components
*/
export interface AccountRow {
id: string;
email: string;
provider: string;
displayName: string;
isDefault: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
color: string;
}
export interface ProviderStats {
provider: string;
displayName: string;
totalRequests: number;
successCount: number;
failureCount: number;
accountCount: number;
accounts: AccountRow[];
}
@@ -0,0 +1,29 @@
/**
* Utility functions and constants for Auth Monitor
*/
/** Calculate success rate percentage */
export function getSuccessRate(success: number, failure: number): number {
const total = success + failure;
if (total === 0) return 100;
return Math.round((success / total) * 100);
}
/** Strip common email domains for cleaner display */
export function cleanEmail(email: string): string {
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
}
// Vibrant colors for account segments - darker for light theme contrast
export const ACCOUNT_COLORS = [
'#1e6091', // Deep Cerulean
'#2d8a6e', // Deep Seaweed
'#d4a012', // Dark Tuscan
'#c92a2d', // Deep Strawberry
'#c45a1a', // Deep Pumpkin
'#6b9c4d', // Dark Willow
'#3d5a73', // Deep Blue Slate
'#cc7614', // Dark Carrot
'#3a7371', // Deep Cyan
'#7c5fc4', // Deep Purple
];
+1
View File
@@ -4,6 +4,7 @@
// Main monitoring components
export { AuthMonitor } from './auth-monitor';
export type { AccountRow, ProviderStats } from './auth-monitor';
export { ProxyStatusWidget } from './proxy-status-widget';
// Error logs (from subdirectory)
+7 -2
View File
@@ -4,7 +4,7 @@
*/
/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
@@ -16,7 +16,7 @@ import { FriendlyUISection } from './friendly-ui-section';
import { RawEditorSection } from './raw-editor-section';
import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: ProfileEditorProps) {
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
const [conflictDialog, setConflictDialog] = useState(false);
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
@@ -100,6 +100,11 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
return Object.keys(localEdits).length > 0;
}, [rawJsonEdits, localEdits, settings]);
// Notify parent of hasChanges state
useEffect(() => {
onHasChangesUpdate?.(computedHasChanges);
}, [computedHasChanges, onHasChangesUpdate]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
@@ -16,4 +16,5 @@ export interface SettingsResponse {
export interface ProfileEditorProps {
profileName: string;
onDelete?: () => void;
onHasChangesUpdate?: (hasChanges: boolean) => void;
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function ConfirmDialog({
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className={variant === 'destructive' ? 'bg-red-600 hover:bg-red-700' : ''}
className={variant === 'destructive' ? 'bg-red-600 hover:bg-red-700 text-white' : ''}
>
{confirmText}
</AlertDialogAction>
+15 -6
View File
@@ -5,7 +5,15 @@
*/
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import {
createContext,
useContext,
useState,
useEffect,
useMemo,
useCallback,
type ReactNode,
} from 'react';
interface PrivacyContextValue {
/** Whether privacy/demo mode is enabled */
@@ -32,13 +40,14 @@ export function PrivacyProvider({ children }: { children: ReactNode }) {
localStorage.setItem(STORAGE_KEY, String(privacyMode));
}, [privacyMode]);
const togglePrivacyMode = () => setPrivacyMode((prev) => !prev);
const togglePrivacyMode = useCallback(() => setPrivacyMode((prev) => !prev), []);
return (
<PrivacyContext.Provider value={{ privacyMode, togglePrivacyMode }}>
{children}
</PrivacyContext.Provider>
const value = useMemo(
() => ({ privacyMode, togglePrivacyMode }),
[privacyMode, togglePrivacyMode]
);
return <PrivacyContext.Provider value={value}>{children}</PrivacyContext.Provider>;
}
export function usePrivacy() {
+9 -6
View File
@@ -3,7 +3,7 @@
* Manages popup-based OAuth authentication flows
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@@ -180,9 +180,12 @@ export function useCliproxyAuthFlow() {
setState({ provider: null, isAuthenticating: false, error: null });
}, [cleanup]);
return {
...state,
startAuth,
cancelAuth,
};
return useMemo(
() => ({
...state,
startAuth,
cancelAuth,
}),
[state, startAuth, cancelAuth]
);
}
+76 -40
View File
@@ -4,6 +4,7 @@
* React hook for managing GitHub Copilot integration state.
*/
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api';
@@ -247,54 +248,89 @@ export function useCopilot() {
},
});
return {
// Status
status: statusQuery.data,
statusLoading: statusQuery.isLoading,
statusError: statusQuery.error,
refetchStatus: statusQuery.refetch,
return useMemo(
() => ({
// Status
status: statusQuery.data,
statusLoading: statusQuery.isLoading,
statusError: statusQuery.error,
refetchStatus: statusQuery.refetch,
// Config
config: configQuery.data,
configLoading: configQuery.isLoading,
// Config
config: configQuery.data,
configLoading: configQuery.isLoading,
// Models
models: modelsQuery.data?.models ?? [],
currentModel: modelsQuery.data?.current,
modelsLoading: modelsQuery.isLoading,
// Models
models: modelsQuery.data?.models ?? [],
currentModel: modelsQuery.data?.current,
modelsLoading: modelsQuery.isLoading,
// Raw Settings
rawSettings: rawSettingsQuery.data,
rawSettingsLoading: rawSettingsQuery.isLoading,
refetchRawSettings: rawSettingsQuery.refetch,
// Raw Settings
rawSettings: rawSettingsQuery.data,
rawSettingsLoading: rawSettingsQuery.isLoading,
refetchRawSettings: rawSettingsQuery.refetch,
// Mutations
updateConfig: updateConfigMutation.mutate,
updateConfigAsync: updateConfigMutation.mutateAsync,
isUpdating: updateConfigMutation.isPending,
// Mutations
updateConfig: updateConfigMutation.mutate,
updateConfigAsync: updateConfigMutation.mutateAsync,
isUpdating: updateConfigMutation.isPending,
saveRawSettings: saveRawSettingsMutation.mutate,
saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync,
isSavingRawSettings: saveRawSettingsMutation.isPending,
saveRawSettings: saveRawSettingsMutation.mutate,
saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync,
isSavingRawSettings: saveRawSettingsMutation.isPending,
startAuth: startAuthMutation.mutate,
startAuthAsync: startAuthMutation.mutateAsync,
isAuthenticating: startAuthMutation.isPending,
authResult: startAuthMutation.data,
startAuth: startAuthMutation.mutate,
startAuthAsync: startAuthMutation.mutateAsync,
isAuthenticating: startAuthMutation.isPending,
authResult: startAuthMutation.data,
startDaemon: startDaemonMutation.mutate,
isStartingDaemon: startDaemonMutation.isPending,
startDaemon: startDaemonMutation.mutate,
isStartingDaemon: startDaemonMutation.isPending,
stopDaemon: stopDaemonMutation.mutate,
isStoppingDaemon: stopDaemonMutation.isPending,
stopDaemon: stopDaemonMutation.mutate,
isStoppingDaemon: stopDaemonMutation.isPending,
// Install
info: infoQuery.data,
infoLoading: infoQuery.isLoading,
refetchInfo: infoQuery.refetch,
// Install
info: infoQuery.data,
infoLoading: infoQuery.isLoading,
refetchInfo: infoQuery.refetch,
install: installMutation.mutate,
installAsync: installMutation.mutateAsync,
isInstalling: installMutation.isPending,
};
install: installMutation.mutate,
installAsync: installMutation.mutateAsync,
isInstalling: installMutation.isPending,
}),
[
statusQuery.data,
statusQuery.isLoading,
statusQuery.error,
statusQuery.refetch,
configQuery.data,
configQuery.isLoading,
modelsQuery.data,
modelsQuery.isLoading,
rawSettingsQuery.data,
rawSettingsQuery.isLoading,
rawSettingsQuery.refetch,
updateConfigMutation.mutate,
updateConfigMutation.mutateAsync,
updateConfigMutation.isPending,
saveRawSettingsMutation.mutate,
saveRawSettingsMutation.mutateAsync,
saveRawSettingsMutation.isPending,
startAuthMutation.mutate,
startAuthMutation.mutateAsync,
startAuthMutation.isPending,
startAuthMutation.data,
startDaemonMutation.mutate,
startDaemonMutation.isPending,
stopDaemonMutation.mutate,
stopDaemonMutation.isPending,
infoQuery.data,
infoQuery.isLoading,
infoQuery.refetch,
installMutation.mutate,
installMutation.mutateAsync,
installMutation.isPending,
]
);
}
+10 -7
View File
@@ -5,7 +5,7 @@
* Used in conjunction with ProjectSelectionDialog component.
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
interface GCloudProject {
id: string;
@@ -102,10 +102,13 @@ export function useProjectSelection() {
setState({ isOpen: false, prompt: null });
}, []);
return {
isOpen: state.isOpen,
prompt: state.prompt,
onSelect: handleSelect,
onClose: handleClose,
};
return useMemo(
() => ({
isOpen: state.isOpen,
prompt: state.prompt,
onSelect: handleSelect,
onClose: handleClose,
}),
[state.isOpen, state.prompt, handleSelect, handleClose]
);
}
+2 -2
View File
@@ -4,7 +4,7 @@
* Manages WebSocket connection, auto-reconnect, and React Query invalidation.
*/
import { useEffect, useState, useCallback, useRef } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@@ -141,5 +141,5 @@ export function useWebSocket() {
return () => clearInterval(interval);
}, []);
return { status, connect, disconnect };
return useMemo(() => ({ status, connect, disconnect }), [status, connect, disconnect]);
}
+6 -4
View File
@@ -308,16 +308,18 @@ export const api = {
// Multi-account management
accounts: {
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'),
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/auth/accounts'),
listByProvider: (provider: string) =>
request<{ provider: string; accounts: OAuthAccount[] }>(`/cliproxy/accounts/${provider}`),
request<{ provider: string; accounts: OAuthAccount[] }>(
`/cliproxy/auth/accounts/${provider}`
),
setDefault: (provider: string, accountId: string) =>
request(`/cliproxy/accounts/${provider}/default`, {
request(`/cliproxy/auth/accounts/${provider}/default`, {
method: 'POST',
body: JSON.stringify({ accountId }),
}),
remove: (provider: string, accountId: string) =>
request(`/cliproxy/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
},
// OAuth flow
auth: {
+1 -1
View File
@@ -34,7 +34,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
badge: '349+ models',
featured: true,
icon: '/icons/openrouter.svg',
defaultModel: 'anthropic/claude-sonnet-4',
defaultModel: 'anthropic/claude-opus-4.5',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
-420
View File
@@ -1,420 +0,0 @@
/**
* Analytics Page
*
* Displays Claude Code usage analytics with charts.
* Features trend charts, model breakdown, cost analysis, and anomaly detection.
*/
import { useState, useMemo, useRef, useCallback } from 'react';
import type { DateRange } from 'react-day-picker';
import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
import {
useUsageSummary,
useUsageTrends,
useHourlyUsage,
useModelUsage,
useRefreshUsage,
useUsageStatus,
useSessions,
type ModelUsage,
} from '@/hooks/use-usage';
import { getModelColor, cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
// Format token count to human-readable (K/M/B)
function formatTokens(num: number): string {
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
return num.toString();
}
export function AnalyticsPage() {
const { privacyMode } = usePrivacy();
// Default to last 30 days
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subDays(new Date(), 30),
to: new Date(),
});
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
const popoverAnchorRef = useRef<HTMLDivElement>(null);
// Refresh hook
const refreshUsage = useRefreshUsage();
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refreshUsage();
} finally {
setIsRefreshing(false);
}
};
// Convert dates to API format
const apiOptions = {
startDate: dateRange?.from,
endDate: dateRange?.to,
};
// Fetch data
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
const { data: status } = useUsageStatus();
// Handle "24H" preset click
const handleTodayClick = useCallback(() => {
const now = new Date();
setDateRange({ from: subDays(now, 1), to: now });
setViewMode('hourly');
}, []);
// Handle date range changes from DateRangeFilter
const handleDateRangeChange = useCallback((range: DateRange | undefined) => {
setDateRange(range);
setViewMode('daily'); // Switch back to daily view for multi-day ranges
}, []);
// Format "Last updated" text
const lastUpdatedText = useMemo(() => {
if (!status?.lastFetch) return null;
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
}, [status?.lastFetch]);
// Handle model click for popover
const handleModelClick = useCallback((model: ModelUsage, event: React.MouseEvent) => {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
setPopoverPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
setSelectedModel(model);
}, []);
const handlePopoverClose = useCallback(() => {
setSelectedModel(null);
setPopoverPosition(null);
}, []);
return (
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-xl font-semibold">Analytics</h1>
<p className="text-sm text-muted-foreground">Track usage & insights</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'hourly' ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={handleTodayClick}
>
24H
</Button>
<DateRangeFilter
value={dateRange}
onChange={handleDateRangeChange}
presets={[
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
{ label: 'All Time', range: { from: undefined, to: new Date() } },
]}
/>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText}
</span>
)}
<Button
variant="outline"
size="sm"
className="gap-2 h-8"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0 gap-4">
{/* Usage Trend Chart - Full Width */}
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
<CardHeader className="px-3 py-2 shrink-0">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
{viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'}
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
<UsageTrendChart
data={viewMode === 'hourly' ? hourlyData || [] : trends || []}
isLoading={viewMode === 'hourly' ? isHourlyLoading : isTrendsLoading}
granularity={viewMode === 'hourly' ? 'hourly' : 'daily'}
/>
</CardContent>
</Card>
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (2) + CLIProxy Stats (2) */}
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
{/* Cost by Model - 4/10 width with breakdown */}
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<DollarSign className="w-4 h-4" />
Cost by Model
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 overflow-y-auto">
{isModelsLoading ? (
<Skeleton className="h-full w-full" />
) : (
<div className="space-y-0.5">
{[...(models || [])]
.sort((a, b) => b.cost - a.cost)
.map((model) => (
<button
key={model.model}
className="group flex items-center text-xs w-full hover:bg-muted/50 rounded px-2 py-1.5 transition-colors cursor-pointer gap-3"
onClick={(e) => handleModelClick(model, e)}
title="Click for details"
>
{/* Model name */}
<div className="flex items-center gap-2 min-w-0 w-[180px] shrink-0">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span className="font-medium truncate group-hover:underline underline-offset-2">
{model.model}
</span>
</div>
{/* Cost breakdown mini-bar */}
<div className="flex-1 flex items-center gap-1 min-w-0">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden flex">
<div
className="h-full"
style={{
backgroundColor: '#335c67',
width: `${model.cost > 0 ? (model.costBreakdown.input.cost / model.cost) * 100 : 0}%`,
}}
title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: '#fff3b0',
width: `${model.cost > 0 ? (model.costBreakdown.output.cost / model.cost) * 100 : 0}%`,
}}
title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: '#e09f3e',
width: `${model.cost > 0 ? (model.costBreakdown.cacheCreation.cost / model.cost) * 100 : 0}%`,
}}
title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: '#9e2a2b',
width: `${model.cost > 0 ? (model.costBreakdown.cacheRead.cost / model.cost) * 100 : 0}%`,
}}
title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`}
/>
</div>
</div>
{/* Token count */}
<span
className={cn(
'text-[10px] text-muted-foreground w-14 text-right shrink-0',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{formatTokens(model.tokens)}
</span>
{/* Total cost */}
<span
className={cn(
'font-mono font-medium w-16 text-right shrink-0',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
${model.cost.toFixed(2)}
</span>
<ChevronRight className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity shrink-0" />
</button>
))}
{/* Legend */}
<div className="flex items-center gap-3 pt-2 px-2 text-[10px] text-muted-foreground border-t mt-2">
<span className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: '#335c67' }}
/>
Input
</span>
<span className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full border border-muted-foreground/30"
style={{ backgroundColor: '#fff3b0' }}
/>
Output
</span>
<span className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: '#e09f3e' }}
/>
Cache Write
</span>
<span className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: '#9e2a2b' }}
/>
Cache Read
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Model Distribution - 2/10 width */}
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-2">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<PieChart className="w-4 h-4" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 flex items-center justify-center">
<ModelBreakdownChart
data={models || []}
isLoading={isModelsLoading}
className="h-full w-full"
/>
</CardContent>
</Card>
{/* Session Stats - 2/10 width */}
<SessionStatsCard
data={sessions}
isLoading={isSessionsLoading}
className="lg:col-span-2"
/>
{/* CLIProxy Stats - 2/10 width */}
<CliproxyStatsCard isLoading={isSummaryLoading} className="lg:col-span-2" />
</div>
{/* Model Details Popover - positioned at cursor */}
<Popover open={!!selectedModel} onOpenChange={(open) => !open && handlePopoverClose()}>
<PopoverAnchor asChild>
<div
ref={popoverAnchorRef}
className="fixed pointer-events-none"
style={{
left: popoverPosition?.x ?? 0,
top: popoverPosition?.y ?? 0,
width: 1,
height: 1,
}}
/>
</PopoverAnchor>
<PopoverContent className="w-80 p-3" side="top" align="center">
{selectedModel && <ModelDetailsContent model={selectedModel} />}
</PopoverContent>
</Popover>
</div>
</div>
);
}
export function AnalyticsSkeleton() {
return (
<div className="space-y-4 h-full overflow-hidden">
{/* Usage Trends Skeleton */}
<Card className="flex flex-col min-h-[300px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<Skeleton className="h-full w-full" />
</CardContent>
</Card>
{/* Bottom Row Skeletons */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Cost Breakdown Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Skeleton className="w-2.5 h-2.5 rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
{/* Model Usage Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<div className="flex w-full h-full items-center">
<div className="flex-1 flex justify-center">
<Skeleton className="h-[180px] w-[180px] rounded-full" />
</div>
<div className="w-[140px] shrink-0 pl-2 space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
/**
* Analytics Header Component
*
* Title, date filter, 24H button, and refresh controls.
*/
import type { DateRange } from 'react-day-picker';
import { subDays, startOfMonth } from 'date-fns';
import { Button } from '@/components/ui/button';
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
import { RefreshCw } from 'lucide-react';
interface AnalyticsHeaderProps {
dateRange: DateRange | undefined;
onDateRangeChange: (range: DateRange | undefined) => void;
onTodayClick: () => void;
onRefresh: () => void;
isRefreshing: boolean;
lastUpdatedText: string | null;
viewMode: 'daily' | 'hourly';
}
export function AnalyticsHeader({
dateRange,
onDateRangeChange,
onTodayClick,
onRefresh,
isRefreshing,
lastUpdatedText,
viewMode,
}: AnalyticsHeaderProps) {
return (
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-xl font-semibold">Analytics</h1>
<p className="text-sm text-muted-foreground">Track usage & insights</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'hourly' ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={onTodayClick}
>
24H
</Button>
<DateRangeFilter
value={dateRange}
onChange={onDateRangeChange}
presets={[
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
{ label: 'All Time', range: { from: undefined, to: new Date() } },
]}
/>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText}
</span>
)}
<Button
variant="outline"
size="sm"
className="gap-2 h-8"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
/**
* Analytics Skeleton Component
*
* Loading skeleton for the analytics page.
*/
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function AnalyticsSkeleton() {
return (
<div className="space-y-4 h-full overflow-hidden">
{/* Usage Trends Skeleton */}
<Card className="flex flex-col min-h-[300px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<Skeleton className="h-full w-full" />
</CardContent>
</Card>
{/* Bottom Row Skeletons */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Cost Breakdown Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Skeleton className="w-2.5 h-2.5 rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
{/* Model Usage Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<div className="flex w-full h-full items-center">
<div className="flex-1 flex justify-center">
<Skeleton className="h-[180px] w-[180px] rounded-full" />
</div>
<div className="w-[140px] shrink-0 pl-2 space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,100 @@
/**
* Charts Grid Component
*
* Layout grid for analytics charts and cards.
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
import { TrendingUp, PieChart } from 'lucide-react';
import { usePrivacy } from '@/contexts/privacy-context';
import { CostByModelCard } from './cost-by-model-card';
import type { ModelUsage, PaginatedSessions, DailyUsage, HourlyUsage } from '@/hooks/use-usage';
interface ChartsGridProps {
viewMode: 'daily' | 'hourly';
trends: DailyUsage[] | undefined;
hourlyData: HourlyUsage[] | undefined;
models: ModelUsage[] | undefined;
sessions: PaginatedSessions | undefined;
isTrendsLoading: boolean;
isHourlyLoading: boolean;
isModelsLoading: boolean;
isSessionsLoading: boolean;
isSummaryLoading: boolean;
onModelClick: (model: ModelUsage, event: React.MouseEvent) => void;
}
export function ChartsGrid({
viewMode,
trends,
hourlyData,
models,
sessions,
isTrendsLoading,
isHourlyLoading,
isModelsLoading,
isSessionsLoading,
isSummaryLoading,
onModelClick,
}: ChartsGridProps) {
const { privacyMode } = usePrivacy();
return (
<div className="flex-1 flex flex-col min-h-0 gap-4">
{/* Usage Trend Chart - Full Width */}
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
<CardHeader className="px-3 py-2 shrink-0">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
{viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'}
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
<UsageTrendChart
data={viewMode === 'hourly' ? hourlyData || [] : trends || []}
isLoading={viewMode === 'hourly' ? isHourlyLoading : isTrendsLoading}
granularity={viewMode === 'hourly' ? 'hourly' : 'daily'}
/>
</CardContent>
</Card>
{/* Bottom Row */}
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
{/* Cost by Model */}
<CostByModelCard
models={models}
isLoading={isModelsLoading}
onModelClick={onModelClick}
privacyMode={privacyMode}
/>
{/* Model Distribution */}
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-2">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<PieChart className="w-4 h-4" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 flex items-center justify-center">
<ModelBreakdownChart
data={models || []}
isLoading={isModelsLoading}
className="h-full w-full"
/>
</CardContent>
</Card>
{/* Session Stats */}
<SessionStatsCard data={sessions} isLoading={isSessionsLoading} className="lg:col-span-2" />
{/* CLIProxy Stats */}
<CliproxyStatsCard isLoading={isSummaryLoading} className="lg:col-span-2" />
</div>
</div>
);
}
@@ -0,0 +1,163 @@
/**
* Cost By Model Card Component
*
* Displays a list of models sorted by cost with breakdown bars.
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { DollarSign, ChevronRight } from 'lucide-react';
import { getModelColor, cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { formatTokens } from '../utils';
import type { ModelUsage } from '@/hooks/use-usage';
interface CostByModelCardProps {
models: ModelUsage[] | undefined;
isLoading: boolean;
onModelClick: (model: ModelUsage, event: React.MouseEvent) => void;
privacyMode: boolean;
}
export function CostByModelCard({
models,
isLoading,
onModelClick,
privacyMode,
}: CostByModelCardProps) {
return (
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<DollarSign className="w-4 h-4" />
Cost by Model
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 overflow-y-auto">
{isLoading ? (
<Skeleton className="h-full w-full" />
) : (
<div className="space-y-0.5">
{[...(models || [])]
.sort((a, b) => b.cost - a.cost)
.map((model) => (
<button
key={model.model}
className="group flex items-center text-xs w-full hover:bg-muted/50 rounded px-2 py-1.5 transition-colors cursor-pointer gap-3"
onClick={(e) => onModelClick(model, e)}
title="Click for details"
>
{/* Model name */}
<div className="flex items-center gap-2 min-w-0 w-[180px] shrink-0">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span className="font-medium truncate group-hover:underline underline-offset-2">
{model.model}
</span>
</div>
{/* Cost breakdown mini-bar */}
<CostBreakdownBar model={model} />
{/* Token count */}
<span
className={cn(
'text-[10px] text-muted-foreground w-14 text-right shrink-0',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{formatTokens(model.tokens)}
</span>
{/* Total cost */}
<span
className={cn(
'font-mono font-medium w-16 text-right shrink-0',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
${model.cost.toFixed(2)}
</span>
<ChevronRight className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity shrink-0" />
</button>
))}
{/* Legend */}
<CostLegend />
</div>
)}
</CardContent>
</Card>
);
}
function CostBreakdownBar({ model }: { model: ModelUsage }) {
const colors = {
input: '#335c67',
output: '#fff3b0',
cacheWrite: '#e09f3e',
cacheRead: '#9e2a2b',
};
const getWidth = (cost: number) => (model.cost > 0 ? (cost / model.cost) * 100 : 0);
return (
<div className="flex-1 flex items-center gap-1 min-w-0">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden flex">
<div
className="h-full"
style={{
backgroundColor: colors.input,
width: `${getWidth(model.costBreakdown.input.cost)}%`,
}}
title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: colors.output,
width: `${getWidth(model.costBreakdown.output.cost)}%`,
}}
title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: colors.cacheWrite,
width: `${getWidth(model.costBreakdown.cacheCreation.cost)}%`,
}}
title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: colors.cacheRead,
width: `${getWidth(model.costBreakdown.cacheRead.cost)}%`,
}}
title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`}
/>
</div>
</div>
);
}
function CostLegend() {
const items = [
{ color: '#335c67', label: 'Input' },
{ color: '#fff3b0', label: 'Output', hasBorder: true },
{ color: '#e09f3e', label: 'Cache Write' },
{ color: '#9e2a2b', label: 'Cache Read' },
];
return (
<div className="flex items-center gap-3 pt-2 px-2 text-[10px] text-muted-foreground border-t mt-2">
{items.map(({ color, label, hasBorder }) => (
<span key={label} className="flex items-center gap-1">
<div
className={cn('w-2 h-2 rounded-full', hasBorder && 'border border-muted-foreground/30')}
style={{ backgroundColor: color }}
/>
{label}
</span>
))}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Analytics Page Hooks
*
* Composite hook centralizing all data fetching and state for the analytics page.
*/
import { useState, useMemo, useCallback } from 'react';
import type { DateRange } from 'react-day-picker';
import { subDays, formatDistanceToNow } from 'date-fns';
import {
useUsageSummary,
useUsageTrends,
useHourlyUsage,
useModelUsage,
useRefreshUsage,
useUsageStatus,
useSessions,
type ModelUsage,
} from '@/hooks/use-usage';
export function useAnalyticsPage() {
// Default to last 30 days
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subDays(new Date(), 30),
to: new Date(),
});
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
// Refresh hook
const refreshUsage = useRefreshUsage();
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
await refreshUsage();
} finally {
setIsRefreshing(false);
}
}, [refreshUsage]);
// Convert dates to API format - memoized to prevent unnecessary re-renders
const apiOptions = useMemo(
() => ({
startDate: dateRange?.from,
endDate: dateRange?.to,
}),
[dateRange?.from, dateRange?.to]
);
// Fetch data
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
const { data: status } = useUsageStatus();
// Handle "24H" preset click
const handleTodayClick = useCallback(() => {
const now = new Date();
setDateRange({ from: subDays(now, 1), to: now });
setViewMode('hourly');
}, []);
// Handle date range changes from DateRangeFilter
const handleDateRangeChange = useCallback((range: DateRange | undefined) => {
setDateRange(range);
setViewMode('daily'); // Switch back to daily view for multi-day ranges
}, []);
// Format "Last updated" text
const lastUpdatedText = useMemo(() => {
if (!status?.lastFetch) return null;
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
}, [status?.lastFetch]);
// Handle model click for popover
const handleModelClick = useCallback((model: ModelUsage, event: React.MouseEvent) => {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
setPopoverPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
setSelectedModel(model);
}, []);
const handlePopoverClose = useCallback(() => {
setSelectedModel(null);
setPopoverPosition(null);
}, []);
return {
// State
dateRange,
isRefreshing,
viewMode,
selectedModel,
popoverPosition,
// Data
summary,
trends,
hourlyData,
models,
sessions,
status,
// Loading states
isSummaryLoading,
isTrendsLoading,
isHourlyLoading,
isModelsLoading,
isSessionsLoading,
// Combined loading
isLoading: isSummaryLoading || isTrendsLoading || isModelsLoading || isSessionsLoading,
// Handlers
handleRefresh,
handleTodayClick,
handleDateRangeChange,
handleModelClick,
handlePopoverClose,
// Text
lastUpdatedText,
};
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Analytics Page
*
* Displays Claude Code usage analytics with charts.
* Features trend charts, model breakdown, cost analysis, and anomaly detection.
*/
import { useRef } from 'react';
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
import { useAnalyticsPage } from './hooks';
import { AnalyticsHeader } from './components/analytics-header';
import { ChartsGrid } from './components/charts-grid';
export function AnalyticsPage() {
const popoverAnchorRef = useRef<HTMLDivElement>(null);
const {
dateRange,
handleDateRangeChange,
handleTodayClick,
handleRefresh,
isRefreshing,
lastUpdatedText,
viewMode,
summary,
isSummaryLoading,
trends,
hourlyData,
models,
sessions,
isTrendsLoading,
isHourlyLoading,
isModelsLoading,
isSessionsLoading,
handleModelClick,
selectedModel,
popoverPosition,
handlePopoverClose,
} = useAnalyticsPage();
return (
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
{/* Header */}
<AnalyticsHeader
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
onTodayClick={handleTodayClick}
onRefresh={handleRefresh}
isRefreshing={isRefreshing}
lastUpdatedText={lastUpdatedText}
viewMode={viewMode}
/>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Charts Grid */}
<ChartsGrid
viewMode={viewMode}
trends={trends}
hourlyData={hourlyData}
models={models}
sessions={sessions}
isTrendsLoading={isTrendsLoading}
isHourlyLoading={isHourlyLoading}
isModelsLoading={isModelsLoading}
isSessionsLoading={isSessionsLoading}
isSummaryLoading={isSummaryLoading}
onModelClick={handleModelClick}
/>
{/* Model Details Popover - positioned at cursor */}
<Popover open={!!selectedModel} onOpenChange={(open) => !open && handlePopoverClose()}>
<PopoverAnchor asChild>
<div
ref={popoverAnchorRef}
className="fixed pointer-events-none"
style={{
left: popoverPosition?.x ?? 0,
top: popoverPosition?.y ?? 0,
width: 1,
height: 1,
}}
/>
</PopoverAnchor>
<PopoverContent className="w-80 p-3" side="top" align="center">
{selectedModel && <ModelDetailsContent model={selectedModel} />}
</PopoverContent>
</Popover>
</div>
);
}
// Re-export skeleton for route-level loading
export { AnalyticsSkeleton } from './components/analytics-skeleton';
+19
View File
@@ -0,0 +1,19 @@
/**
* Analytics Page Types
*/
import type { DateRange } from 'react-day-picker';
import type { ModelUsage } from '@/hooks/use-usage';
export interface AnalyticsPageState {
dateRange: DateRange | undefined;
isRefreshing: boolean;
selectedModel: ModelUsage | null;
popoverPosition: { x: number; y: number } | null;
viewMode: 'daily' | 'hourly';
}
export interface DatePreset {
label: string;
range: DateRange;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Analytics Page Utilities
*/
/**
* Format token count to human-readable (K/M/B)
*/
export function formatTokens(num: number): string {
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
return num.toString();
}
+35 -4
View File
@@ -37,6 +37,8 @@ export function ApiPage() {
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
const [createMode, setCreateMode] = useState<'normal' | 'openrouter'>('normal');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [editorHasChanges, setEditorHasChanges] = useState(false);
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
// Prefetch OpenRouter models when page loads (lazy - won't block render)
useOpenRouterModels();
@@ -71,7 +73,21 @@ export function ApiPage() {
// Handle create success
const handleCreateSuccess = (name: string) => {
setCreateDialogOpen(false);
setSelectedProfile(name);
// Use the same unsaved changes check as profile selection
if (editorHasChanges && selectedProfile !== null) {
setPendingSwitch(name);
} else {
setSelectedProfile(name);
}
};
// Handle profile selection with unsaved changes check
const handleProfileSelect = (name: string) => {
if (editorHasChanges && selectedProfile !== name) {
setPendingSwitch(name);
} else {
setSelectedProfile(name);
}
};
return (
@@ -168,9 +184,7 @@ export function ApiPage() {
key={profile.name}
profile={profile}
isSelected={selectedProfile === profile.name}
onSelect={() => {
setSelectedProfile(profile.name);
}}
onSelect={() => handleProfileSelect(profile.name)}
onDelete={() => setDeleteConfirm(profile.name)}
/>
))}
@@ -206,8 +220,10 @@ export function ApiPage() {
<div className="flex-1 flex flex-col min-w-0">
{selectedProfileData ? (
<ProfileEditor
key={selectedProfileData.name}
profileName={selectedProfileData.name}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
onHasChangesUpdate={setEditorHasChanges}
/>
) : (
<OpenRouterQuickStart
@@ -242,6 +258,21 @@ export function ApiPage() {
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)}
/>
{/* Unsaved Changes Confirmation */}
<ConfirmDialog
open={!!pendingSwitch}
title="Unsaved Changes"
description={`You have unsaved changes in "${selectedProfile}". Discard and switch to "${pendingSwitch}"?`}
confirmText="Discard & Switch"
variant="destructive"
onConfirm={() => {
setEditorHasChanges(false);
setSelectedProfile(pendingSwitch);
setPendingSwitch(null);
}}
onCancel={() => setPendingSwitch(null)}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
/**
* Section Skeleton Component
* Loading placeholder for lazy-loaded settings sections
*/
import { Skeleton } from '@/components/ui/skeleton';
export function SectionSkeleton() {
return (
<div className="flex-1 p-5 space-y-6">
<Skeleton className="h-4 w-2/3" />
<div className="p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-6 w-10 rounded-full" />
</div>
</div>
<div className="space-y-3">
<Skeleton className="h-5 w-24" />
<div className="space-y-2">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
/**
* Tab Navigation Component
* Settings page tab switcher with icons
*/
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Globe, Settings2, Server } from 'lucide-react';
import type { SettingsTab } from '../types';
interface TabNavigationProps {
activeTab: SettingsTab;
onTabChange: (tab: SettingsTab) => void;
}
export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
return (
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SettingsTab)}>
<TabsList className="w-full">
<TabsTrigger value="websearch" className="flex-1 gap-2">
<Globe className="w-4 h-4" />
WebSearch
</TabsTrigger>
<TabsTrigger value="globalenv" className="flex-1 gap-2">
<Settings2 className="w-4 h-4" />
Global Env
</TabsTrigger>
<TabsTrigger value="proxy" className="flex-1 gap-2">
<Server className="w-4 h-4" />
Proxy
</TabsTrigger>
</TabsList>
</Tabs>
);
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Settings Provider Component
* React component that provides settings context
*/
import { useReducer, type ReactNode } from 'react';
import { SettingsContext, settingsReducer, initialSettingsState } from './settings-context';
interface SettingsProviderProps {
children: ReactNode;
}
export function SettingsProvider({ children }: SettingsProviderProps) {
const [state, dispatch] = useReducer(settingsReducer, initialSettingsState);
return (
<SettingsContext.Provider value={{ state, dispatch }}>{children}</SettingsContext.Provider>
);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Settings Hooks - Re-export from hooks directory
*/
export {
useSettingsContext,
useSettingsActions,
useSettingsTab,
useWebSearchConfig,
useGlobalEnvConfig,
useProxyConfig,
useRawConfig,
} from './hooks/index';
@@ -0,0 +1,182 @@
/**
* Context Hooks
* Hooks for accessing and updating settings context state
*/
import { useCallback, useContext, useMemo } from 'react';
import { SettingsContext } from '../settings-context';
import type {
WebSearchConfig,
GlobalEnvConfig,
CliproxyServerConfig,
WebSearchStatus,
RemoteProxyStatus,
} from '../types';
export function useSettingsContext() {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettingsContext must be used within a SettingsProvider');
}
return context;
}
export function useSettingsActions() {
const { dispatch } = useSettingsContext();
const setWebSearchConfig = useCallback(
(config: WebSearchConfig | null) => dispatch({ type: 'SET_WEBSEARCH_CONFIG', payload: config }),
[dispatch]
);
const setWebSearchStatus = useCallback(
(status: WebSearchStatus | null) => dispatch({ type: 'SET_WEBSEARCH_STATUS', payload: status }),
[dispatch]
);
const setWebSearchLoading = useCallback(
(loading: boolean) => dispatch({ type: 'SET_WEBSEARCH_LOADING', payload: loading }),
[dispatch]
);
const setWebSearchStatusLoading = useCallback(
(loading: boolean) => dispatch({ type: 'SET_WEBSEARCH_STATUS_LOADING', payload: loading }),
[dispatch]
);
const setWebSearchSaving = useCallback(
(saving: boolean) => dispatch({ type: 'SET_WEBSEARCH_SAVING', payload: saving }),
[dispatch]
);
const setWebSearchError = useCallback(
(error: string | null) => dispatch({ type: 'SET_WEBSEARCH_ERROR', payload: error }),
[dispatch]
);
const setWebSearchSuccess = useCallback(
(success: boolean) => dispatch({ type: 'SET_WEBSEARCH_SUCCESS', payload: success }),
[dispatch]
);
const setGlobalEnvConfig = useCallback(
(config: GlobalEnvConfig | null) => dispatch({ type: 'SET_GLOBALENV_CONFIG', payload: config }),
[dispatch]
);
const setGlobalEnvLoading = useCallback(
(loading: boolean) => dispatch({ type: 'SET_GLOBALENV_LOADING', payload: loading }),
[dispatch]
);
const setGlobalEnvSaving = useCallback(
(saving: boolean) => dispatch({ type: 'SET_GLOBALENV_SAVING', payload: saving }),
[dispatch]
);
const setGlobalEnvError = useCallback(
(error: string | null) => dispatch({ type: 'SET_GLOBALENV_ERROR', payload: error }),
[dispatch]
);
const setGlobalEnvSuccess = useCallback(
(success: boolean) => dispatch({ type: 'SET_GLOBALENV_SUCCESS', payload: success }),
[dispatch]
);
const setProxyConfig = useCallback(
(config: CliproxyServerConfig | null) =>
dispatch({ type: 'SET_PROXY_CONFIG', payload: config }),
[dispatch]
);
const setProxyLoading = useCallback(
(loading: boolean) => dispatch({ type: 'SET_PROXY_LOADING', payload: loading }),
[dispatch]
);
const setProxySaving = useCallback(
(saving: boolean) => dispatch({ type: 'SET_PROXY_SAVING', payload: saving }),
[dispatch]
);
const setProxyError = useCallback(
(error: string | null) => dispatch({ type: 'SET_PROXY_ERROR', payload: error }),
[dispatch]
);
const setProxySuccess = useCallback(
(success: boolean) => dispatch({ type: 'SET_PROXY_SUCCESS', payload: success }),
[dispatch]
);
const setProxyTestResult = useCallback(
(result: RemoteProxyStatus | null) =>
dispatch({ type: 'SET_PROXY_TEST_RESULT', payload: result }),
[dispatch]
);
const setProxyTesting = useCallback(
(testing: boolean) => dispatch({ type: 'SET_PROXY_TESTING', payload: testing }),
[dispatch]
);
const setRawConfig = useCallback(
(config: string | null) => dispatch({ type: 'SET_RAW_CONFIG', payload: config }),
[dispatch]
);
const setRawConfigLoading = useCallback(
(loading: boolean) => dispatch({ type: 'SET_RAW_CONFIG_LOADING', payload: loading }),
[dispatch]
);
return useMemo(
() => ({
setWebSearchConfig,
setWebSearchStatus,
setWebSearchLoading,
setWebSearchStatusLoading,
setWebSearchSaving,
setWebSearchError,
setWebSearchSuccess,
setGlobalEnvConfig,
setGlobalEnvLoading,
setGlobalEnvSaving,
setGlobalEnvError,
setGlobalEnvSuccess,
setProxyConfig,
setProxyLoading,
setProxySaving,
setProxyError,
setProxySuccess,
setProxyTestResult,
setProxyTesting,
setRawConfig,
setRawConfigLoading,
}),
[
setWebSearchConfig,
setWebSearchStatus,
setWebSearchLoading,
setWebSearchStatusLoading,
setWebSearchSaving,
setWebSearchError,
setWebSearchSuccess,
setGlobalEnvConfig,
setGlobalEnvLoading,
setGlobalEnvSaving,
setGlobalEnvError,
setGlobalEnvSuccess,
setProxyConfig,
setProxyLoading,
setProxySaving,
setProxyError,
setProxySuccess,
setProxyTestResult,
setProxyTesting,
setRawConfig,
setRawConfigLoading,
]
);
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Settings Hooks Barrel Export
*/
export { useSettingsContext, useSettingsActions } from './context-hooks';
export { useSettingsTab } from './use-settings-tab';
export { useWebSearchConfig } from './use-websearch-config';
export { useGlobalEnvConfig } from './use-globalenv-config';
export { useProxyConfig } from './use-proxy-config';
export { useRawConfig } from './use-raw-config';
@@ -0,0 +1,100 @@
/**
* GlobalEnv Config Hook
*/
import { useCallback, useState } from 'react';
import { useSettingsContext, useSettingsActions } from './context-hooks';
import type { GlobalEnvConfig } from '../types';
export function useGlobalEnvConfig() {
const { state } = useSettingsContext();
const actions = useSettingsActions();
const [newEnvKey, setNewEnvKey] = useState('');
const [newEnvValue, setNewEnvValue] = useState('');
const fetchConfig = useCallback(async () => {
try {
actions.setGlobalEnvLoading(true);
actions.setGlobalEnvError(null);
const res = await fetch('/api/global-env');
if (!res.ok) throw new Error('Failed to load Global Env config');
const data = await res.json();
actions.setGlobalEnvConfig(data);
} catch (err) {
actions.setGlobalEnvError((err as Error).message);
} finally {
actions.setGlobalEnvLoading(false);
}
}, [actions]);
const saveConfig = useCallback(
async (updates: Partial<GlobalEnvConfig>) => {
const config = state.globalEnvConfig;
if (!config) return;
const optimisticConfig = { ...config, ...updates };
actions.setGlobalEnvConfig(optimisticConfig);
try {
actions.setGlobalEnvSaving(true);
actions.setGlobalEnvError(null);
const res = await fetch('/api/global-env', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(optimisticConfig),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to save');
}
const data = await res.json();
actions.setGlobalEnvConfig(data.config);
actions.setGlobalEnvSuccess(true);
setTimeout(() => actions.setGlobalEnvSuccess(false), 1500);
} catch (err) {
actions.setGlobalEnvConfig(config);
actions.setGlobalEnvError((err as Error).message);
} finally {
actions.setGlobalEnvSaving(false);
}
},
[state.globalEnvConfig, actions]
);
const addEnvVar = useCallback(() => {
if (!newEnvKey.trim() || !state.globalEnvConfig) return;
const newEnv = { ...state.globalEnvConfig.env, [newEnvKey.trim()]: newEnvValue };
saveConfig({ env: newEnv });
setNewEnvKey('');
setNewEnvValue('');
}, [newEnvKey, newEnvValue, state.globalEnvConfig, saveConfig]);
const removeEnvVar = useCallback(
(key: string) => {
if (!state.globalEnvConfig) return;
const newEnv = { ...state.globalEnvConfig.env };
delete newEnv[key];
saveConfig({ env: newEnv });
},
[state.globalEnvConfig, saveConfig]
);
return {
config: state.globalEnvConfig,
loading: state.globalEnvLoading,
saving: state.globalEnvSaving,
error: state.globalEnvError,
success: state.globalEnvSuccess,
newEnvKey,
setNewEnvKey,
newEnvValue,
setNewEnvValue,
fetchConfig,
saveConfig,
addEnvVar,
removeEnvVar,
};
}
@@ -0,0 +1,117 @@
/**
* Proxy Config Hook
*/
import { useCallback, useState } from 'react';
import { api } from '@/lib/api-client';
import { useSettingsContext, useSettingsActions } from './context-hooks';
import type { CliproxyServerConfig } from '../types';
export function useProxyConfig() {
const { state } = useSettingsContext();
const actions = useSettingsActions();
const [editedHost, setEditedHost] = useState<string | null>(null);
const [editedPort, setEditedPort] = useState<string | null>(null);
const [editedAuthToken, setEditedAuthToken] = useState<string | null>(null);
const [editedLocalPort, setEditedLocalPort] = useState<string | null>(null);
const fetchConfig = useCallback(async () => {
try {
actions.setProxyLoading(true);
actions.setProxyError(null);
const data = await api.cliproxyServer.get();
actions.setProxyConfig(data);
} catch (err) {
actions.setProxyError((err as Error).message);
} finally {
actions.setProxyLoading(false);
}
}, [actions]);
const saveConfig = useCallback(
async (updates: Partial<CliproxyServerConfig>) => {
const config = state.proxyConfig;
if (!config) return;
const optimisticConfig = {
remote: { ...config.remote, ...updates.remote },
fallback: { ...config.fallback, ...updates.fallback },
local: { ...config.local, ...updates.local },
};
actions.setProxyConfig(optimisticConfig);
actions.setProxyTestResult(null);
try {
actions.setProxySaving(true);
actions.setProxyError(null);
const data = await api.cliproxyServer.update(updates);
actions.setProxyConfig(data);
actions.setProxySuccess(true);
setTimeout(() => actions.setProxySuccess(false), 1500);
} catch (err) {
actions.setProxyConfig(config);
actions.setProxyError((err as Error).message);
} finally {
actions.setProxySaving(false);
}
},
[state.proxyConfig, actions]
);
const testConnection = useCallback(
async (params: {
host: string;
port: string;
protocol: 'http' | 'https';
authToken: string;
}) => {
const { host, port, protocol, authToken } = params;
if (!host) {
actions.setProxyError('Host is required');
return;
}
try {
actions.setProxyTesting(true);
actions.setProxyError(null);
actions.setProxyTestResult(null);
const portNum = port ? parseInt(port, 10) : undefined;
const result = await api.cliproxyServer.test({
host,
port: portNum || undefined,
protocol,
authToken: authToken || undefined,
});
actions.setProxyTestResult(result);
} catch (err) {
actions.setProxyError((err as Error).message);
} finally {
actions.setProxyTesting(false);
}
},
[actions]
);
return {
config: state.proxyConfig,
loading: state.proxyLoading,
saving: state.proxySaving,
error: state.proxyError,
success: state.proxySuccess,
testResult: state.proxyTestResult,
testing: state.proxyTesting,
editedHost,
setEditedHost,
editedPort,
setEditedPort,
editedAuthToken,
setEditedAuthToken,
editedLocalPort,
setEditedLocalPort,
fetchConfig,
saveConfig,
testConnection,
};
}
@@ -0,0 +1,48 @@
/**
* Raw Config Hook
*/
import { useCallback, useState } from 'react';
import { useSettingsContext, useSettingsActions } from './context-hooks';
export function useRawConfig() {
const { state } = useSettingsContext();
const actions = useSettingsActions();
const [copied, setCopied] = useState(false);
const fetchRawConfig = useCallback(async () => {
try {
actions.setRawConfigLoading(true);
const res = await fetch('/api/config/raw');
if (!res.ok) {
actions.setRawConfig(null);
return;
}
const text = await res.text();
actions.setRawConfig(text);
} catch {
actions.setRawConfig(null);
} finally {
actions.setRawConfigLoading(false);
}
}, [actions]);
const copyToClipboard = useCallback(async () => {
if (!state.rawConfig) return;
try {
await navigator.clipboard.writeText(state.rawConfig);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Silent fail
}
}, [state.rawConfig]);
return {
rawConfig: state.rawConfig,
loading: state.rawConfigLoading,
copied,
fetchRawConfig,
copyToClipboard,
};
}
@@ -0,0 +1,23 @@
/**
* Settings Tab URL Sync Hook
*/
import { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { SettingsTab } from '../types';
export function useSettingsTab() {
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const activeTab: SettingsTab =
tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch';
const setActiveTab = useCallback(
(tab: SettingsTab) => {
setSearchParams({ tab }, { replace: true });
},
[setSearchParams]
);
return { activeTab, setActiveTab };
}
@@ -0,0 +1,137 @@
/**
* WebSearch Config Hook
*/
import { useCallback, useEffect, useState } from 'react';
import { useSettingsContext, useSettingsActions } from './context-hooks';
import type { WebSearchConfig } from '../types';
export function useWebSearchConfig() {
const { state } = useSettingsContext();
const actions = useSettingsActions();
const [geminiModelInput, setGeminiModelInput] = useState('');
const [opencodeModelInput, setOpencodeModelInput] = useState('');
const [geminiModelSaved, setGeminiModelSaved] = useState(false);
const [opencodeModelSaved, setOpencodeModelSaved] = useState(false);
const fetchConfig = useCallback(async () => {
try {
actions.setWebSearchLoading(true);
actions.setWebSearchError(null);
const res = await fetch('/api/websearch');
if (!res.ok) throw new Error('Failed to load WebSearch config');
const data = await res.json();
actions.setWebSearchConfig(data);
} catch (err) {
actions.setWebSearchError((err as Error).message);
} finally {
actions.setWebSearchLoading(false);
}
}, [actions]);
const fetchStatus = useCallback(async () => {
try {
actions.setWebSearchStatusLoading(true);
const res = await fetch('/api/websearch/status');
if (!res.ok) throw new Error('Failed to load status');
const data = await res.json();
actions.setWebSearchStatus(data);
} catch {
// Silent fail for status
} finally {
actions.setWebSearchStatusLoading(false);
}
}, [actions]);
const saveConfig = useCallback(
async (updates: Partial<WebSearchConfig>) => {
const config = state.webSearchConfig;
if (!config) return;
const optimisticConfig = { ...config, ...updates };
actions.setWebSearchConfig(optimisticConfig);
try {
actions.setWebSearchSaving(true);
actions.setWebSearchError(null);
const res = await fetch('/api/websearch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(optimisticConfig),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to save');
}
const data = await res.json();
actions.setWebSearchConfig(data.websearch);
actions.setWebSearchSuccess(true);
setTimeout(() => actions.setWebSearchSuccess(false), 1500);
} catch (err) {
actions.setWebSearchConfig(config);
actions.setWebSearchError((err as Error).message);
} finally {
actions.setWebSearchSaving(false);
}
},
[state.webSearchConfig, actions]
);
// Sync model inputs with config
useEffect(() => {
if (state.webSearchConfig) {
setGeminiModelInput(state.webSearchConfig.providers?.gemini?.model ?? 'gemini-2.5-flash');
setOpencodeModelInput(
state.webSearchConfig.providers?.opencode?.model ?? 'opencode/grok-code'
);
}
}, [state.webSearchConfig]);
const saveGeminiModel = useCallback(async () => {
const currentModel = state.webSearchConfig?.providers?.gemini?.model ?? 'gemini-2.5-flash';
if (geminiModelInput !== currentModel) {
const providers = state.webSearchConfig?.providers || {};
await saveConfig({
providers: { ...providers, gemini: { ...providers.gemini, model: geminiModelInput } },
});
setGeminiModelSaved(true);
setTimeout(() => setGeminiModelSaved(false), 2000);
}
}, [geminiModelInput, state.webSearchConfig, saveConfig]);
const saveOpencodeModel = useCallback(async () => {
const currentModel = state.webSearchConfig?.providers?.opencode?.model ?? 'opencode/grok-code';
if (opencodeModelInput !== currentModel) {
const providers = state.webSearchConfig?.providers || {};
await saveConfig({
providers: { ...providers, opencode: { ...providers.opencode, model: opencodeModelInput } },
});
setOpencodeModelSaved(true);
setTimeout(() => setOpencodeModelSaved(false), 2000);
}
}, [opencodeModelInput, state.webSearchConfig, saveConfig]);
return {
config: state.webSearchConfig,
status: state.webSearchStatus,
loading: state.webSearchLoading,
statusLoading: state.webSearchStatusLoading,
saving: state.webSearchSaving,
error: state.webSearchError,
success: state.webSearchSuccess,
geminiModelInput,
setGeminiModelInput,
opencodeModelInput,
setOpencodeModelInput,
geminiModelSaved,
opencodeModelSaved,
fetchConfig,
fetchStatus,
saveConfig,
saveGeminiModel,
saveOpencodeModel,
};
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Settings Page
* Main entry point with lazy-loaded sections and URL tab persistence
*/
import { lazy, Suspense, startTransition, useEffect } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { Button } from '@/components/ui/button';
import { RefreshCw, FileCode, Copy, Check, GripVertical } from 'lucide-react';
import { CodeEditor } from '@/components/shared/code-editor';
import { SettingsProvider } from './context';
import { useSettingsTab, useRawConfig } from './hooks';
import { TabNavigation } from './components/tab-navigation';
import { SectionSkeleton } from './components/section-skeleton';
import type { SettingsTab } from './types';
// Lazy-loaded sections
const WebSearchSection = lazy(() => import('./sections/websearch'));
const GlobalEnvSection = lazy(() => import('./sections/globalenv-section'));
const ProxySection = lazy(() => import('./sections/proxy'));
// Inner component that uses context
function SettingsPageInner() {
const { activeTab, setActiveTab } = useSettingsTab();
const {
rawConfig,
loading: rawConfigLoading,
copied,
fetchRawConfig,
copyToClipboard,
} = useRawConfig();
// Fetch raw config on mount
useEffect(() => {
fetchRawConfig();
}, [fetchRawConfig]);
const handleTabChange = (tab: SettingsTab) => {
startTransition(() => {
setActiveTab(tab);
});
};
return (
<div className="h-[calc(100vh-100px)]">
<PanelGroup direction="horizontal" className="h-full">
{/* Left Panel - Settings Controls */}
<Panel defaultSize={40} minSize={30} maxSize={55}>
<div className="h-full border-r flex flex-col bg-muted/30 relative">
{/* Header with Tabs */}
<div className="p-5 border-b bg-background">
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
</div>
{/* Tab Content */}
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'proxy' && <ProxySection />}
</Suspense>
</div>
</Panel>
{/* Resize Handle */}
<PanelResizeHandle className="w-2 bg-border hover:bg-primary/20 transition-colors cursor-col-resize flex items-center justify-center group">
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
</PanelResizeHandle>
{/* Right Panel - Config Viewer */}
<Panel defaultSize={60} minSize={35}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-3">
<FileCode className="w-5 h-5 text-primary" />
<div>
<h2 className="font-semibold">config.yaml</h2>
<p className="text-sm text-muted-foreground">~/.ccs/config.yaml</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={copyToClipboard} disabled={!rawConfig}>
{copied ? (
<>
<Check className="w-4 h-4 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-4 h-4 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={fetchRawConfig}
disabled={rawConfigLoading}
>
<RefreshCw className={`w-4 h-4 ${rawConfigLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Config Content - scrollable */}
<div className="flex-1 overflow-auto">
{rawConfigLoading ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
Loading...
</div>
) : rawConfig ? (
<CodeEditor
value={rawConfig}
onChange={() => {}}
language="yaml"
readonly
minHeight="auto"
className="min-h-full"
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>Config file not found</p>
<code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block">
ccs migrate
</code>
</div>
</div>
)}
</div>
</div>
</Panel>
</PanelGroup>
</div>
);
}
// Main export with context provider
export function SettingsPage() {
return (
<SettingsProvider>
<SettingsPageInner />
</SettingsProvider>
);
}
@@ -0,0 +1,222 @@
/**
* GlobalEnv Section
* Settings section for global environment variables
*/
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react';
import { useGlobalEnvConfig, useRawConfig } from '../hooks';
export default function GlobalEnvSection() {
const {
config,
loading,
saving,
error,
success,
newEnvKey,
setNewEnvKey,
newEnvValue,
setNewEnvValue,
fetchConfig,
saveConfig,
addEnvVar,
removeEnvVar,
} = useGlobalEnvConfig();
const { fetchRawConfig } = useRawConfig();
// Load data on mount
useEffect(() => {
fetchConfig();
fetchRawConfig();
}, [fetchConfig, fetchRawConfig]);
const toggleGlobalEnv = () => {
saveConfig({ enabled: !config?.enabled });
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
</div>
</div>
);
}
return (
<>
{/* Toast-style alerts */}
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
}`}
>
{error && (
<Alert variant="destructive" className="py-2 shadow-lg">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span>
</div>
)}
</div>
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground">
Environment variables injected into all non-Claude subscription profiles (gemini, codex,
agy, copilot, etc.)
</p>
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">
{config?.enabled ? 'Global Env enabled' : 'Global Env disabled'}
</p>
<p className="text-sm text-muted-foreground">
{config?.enabled
? 'Env vars will be injected into third-party profiles'
: 'Env vars will not be injected'}
</p>
</div>
<Switch checked={config?.enabled ?? true} onCheckedChange={toggleGlobalEnv} />
</div>
{/* Current Environment Variables */}
<div className="space-y-3">
<h3 className="text-base font-medium">Environment Variables</h3>
{config?.env && Object.keys(config.env).length > 0 ? (
<div className="space-y-2">
{Object.entries(config.env).map(([key, value]) => (
<div
key={key}
className="flex items-center gap-2 p-3 rounded-lg border bg-background"
>
<code className="flex-1 font-mono text-sm truncate">{key}</code>
<span className="text-muted-foreground">=</span>
<code className="font-mono text-sm px-2 py-1 bg-muted rounded">{value}</code>
<Button
variant="ghost"
size="sm"
onClick={() => removeEnvVar(key)}
disabled={saving}
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
) : (
<div className="p-4 rounded-lg border border-dashed text-center text-muted-foreground">
<p>No environment variables configured</p>
</div>
)}
{/* Add New Variable */}
<div className="p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium mb-3">Add New Variable</h4>
<div className="flex gap-2">
<Input
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
placeholder="KEY_NAME"
className="flex-1 font-mono text-sm h-9"
disabled={saving}
/>
<span className="flex items-center text-muted-foreground">=</span>
<Input
value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)}
placeholder="value"
className="flex-1 font-mono text-sm h-9"
disabled={saving}
/>
<Button
size="sm"
onClick={addEnvVar}
disabled={saving || !newEnvKey.trim()}
className="h-9"
>
<Plus className="w-4 h-4 mr-1" />
Add
</Button>
</div>
</div>
{/* Common Variables Quick Add */}
<div className="p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium mb-3">Quick Add Common Variables</h4>
<div className="flex flex-wrap gap-2">
{[
{ key: 'DISABLE_BUG_COMMAND', value: '1' },
{ key: 'DISABLE_ERROR_REPORTING', value: '1' },
{ key: 'DISABLE_TELEMETRY', value: '1' },
].map(
({ key, value }) =>
!config?.env?.[key] && (
<Button
key={key}
variant="outline"
size="sm"
onClick={() => {
setNewEnvKey(key);
setNewEnvValue(value);
}}
className="text-xs font-mono"
>
+ {key}
</Button>
)
)}
{config?.env &&
['DISABLE_BUG_COMMAND', 'DISABLE_ERROR_REPORTING', 'DISABLE_TELEMETRY'].every(
(k) => config.env[k]
) && (
<span className="text-sm text-muted-foreground">
All common variables are configured
</span>
)}
</div>
</div>
</div>
</div>
</ScrollArea>
{/* Footer */}
<div className="p-4 border-t bg-background">
<Button
variant="outline"
size="sm"
onClick={() => {
fetchConfig();
fetchRawConfig();
}}
disabled={loading || saving}
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</>
);
}
@@ -0,0 +1,289 @@
/**
* Proxy Section
* Settings section for CLIProxyAPI configuration (local/remote)
*/
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { RefreshCw, CheckCircle2, AlertCircle, Laptop, Cloud } from 'lucide-react';
import { useProxyConfig, useRawConfig } from '../../hooks';
import { LocalProxyCard } from './local-proxy-card';
import { RemoteProxyCard } from './remote-proxy-card';
export default function ProxySection() {
const {
config,
loading,
saving,
error,
success,
testResult,
testing,
editedHost,
setEditedHost,
editedPort,
setEditedPort,
editedAuthToken,
setEditedAuthToken,
editedLocalPort,
setEditedLocalPort,
fetchConfig,
saveConfig,
testConnection,
} = useProxyConfig();
const { fetchRawConfig } = useRawConfig();
// Load data on mount
useEffect(() => {
fetchConfig();
fetchRawConfig();
}, [fetchConfig, fetchRawConfig]);
if (loading || !config) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
</div>
</div>
);
}
const isRemoteMode = config.remote.enabled ?? false;
const remoteConfig = config.remote;
const fallbackConfig = config.fallback;
// Get display values (edited or from config)
const hostInput = config.remote.host ?? '';
const portInput = config.remote.port !== undefined ? config.remote.port.toString() : '';
const authTokenInput = config.remote.auth_token ?? '';
const localPortInput = (config.local.port ?? 8317).toString();
const displayHost = editedHost ?? hostInput;
const displayPort = editedPort ?? portInput;
const displayAuthToken = editedAuthToken ?? authTokenInput;
const displayLocalPort = editedLocalPort ?? localPortInput;
// Save functions for blur events
const saveHost = () => {
const value = editedHost ?? displayHost;
if (value !== config.remote.host) {
saveConfig({ remote: { ...remoteConfig, host: value } });
}
setEditedHost(null);
};
const savePort = () => {
const portStr = editedPort ?? displayPort;
const port = portStr === '' ? undefined : parseInt(portStr, 10);
const effectivePort = port && !isNaN(port) && port > 0 ? port : undefined;
if (effectivePort !== config.remote.port) {
saveConfig({ remote: { ...remoteConfig, port: effectivePort } });
}
setEditedPort(null);
};
const saveAuthToken = () => {
const value = editedAuthToken ?? displayAuthToken;
if (value !== config.remote.auth_token) {
saveConfig({ remote: { ...remoteConfig, auth_token: value } });
}
setEditedAuthToken(null);
};
const saveLocalPort = () => {
const port = parseInt(editedLocalPort ?? displayLocalPort, 10);
if (!isNaN(port) && port !== config.local.port) {
saveConfig({ local: { ...config.local, port } });
}
setEditedLocalPort(null);
};
const handleTestConnection = () => {
testConnection({
host: displayHost,
port: displayPort,
protocol: config.remote.protocol || 'http',
authToken: displayAuthToken,
});
};
return (
<>
{/* Toast-style alerts */}
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
}`}
>
{error && (
<Alert variant="destructive" className="py-2 shadow-lg">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span>
</div>
)}
</div>
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground">
Configure local or remote CLIProxyAPI connection for proxy-based profiles
</p>
{/* Mode Toggle - Card based selection */}
<div className="space-y-3">
<h3 className="text-base font-medium">Connection Mode</h3>
<div className="grid grid-cols-2 gap-3">
{/* Local Mode Card */}
<button
onClick={() => saveConfig({ remote: { ...remoteConfig, enabled: false } })}
disabled={saving}
className={`p-4 rounded-lg border-2 text-left transition-all ${
!isRemoteMode
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/50'
}`}
>
<div className="flex items-center gap-3 mb-2">
<Laptop
className={`w-5 h-5 ${!isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
/>
<span className="font-medium">Local</span>
</div>
<p className="text-xs text-muted-foreground">
Run CLIProxyAPI binary on this machine
</p>
</button>
{/* Remote Mode Card */}
<button
onClick={() => saveConfig({ remote: { ...remoteConfig, enabled: true } })}
disabled={saving}
className={`p-4 rounded-lg border-2 text-left transition-all ${
isRemoteMode
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/50'
}`}
>
<div className="flex items-center gap-3 mb-2">
<Cloud
className={`w-5 h-5 ${isRemoteMode ? 'text-primary' : 'text-muted-foreground'}`}
/>
<span className="font-medium">Remote</span>
</div>
<p className="text-xs text-muted-foreground">
Connect to a remote CLIProxyAPI server
</p>
</button>
</div>
</div>
{/* Remote Settings - Show when remote mode is enabled */}
{isRemoteMode && (
<RemoteProxyCard
config={config}
saving={saving}
testing={testing}
testResult={testResult}
displayHost={displayHost}
displayPort={displayPort}
displayAuthToken={displayAuthToken}
setEditedHost={setEditedHost}
setEditedPort={setEditedPort}
setEditedAuthToken={setEditedAuthToken}
onSaveHost={saveHost}
onSavePort={savePort}
onSaveAuthToken={saveAuthToken}
onSaveConfig={saveConfig}
onTestConnection={handleTestConnection}
/>
)}
{/* Fallback Settings */}
<div className="space-y-3">
<h3 className="text-base font-medium">Fallback Settings</h3>
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
{/* Enable Fallback */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Enable fallback to local</p>
<p className="text-xs text-muted-foreground">
Use local proxy if remote is unreachable
</p>
</div>
<Switch
checked={fallbackConfig.enabled ?? true}
onCheckedChange={(checked) =>
saveConfig({ fallback: { ...fallbackConfig, enabled: checked } })
}
disabled={saving || !isRemoteMode}
/>
</div>
{/* Auto-start on fallback */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Auto-start local proxy</p>
<p className="text-xs text-muted-foreground">
Automatically start local proxy on fallback
</p>
</div>
<Switch
checked={fallbackConfig.auto_start ?? false}
onCheckedChange={(checked) =>
saveConfig({ fallback: { ...fallbackConfig, auto_start: checked } })
}
disabled={saving || !isRemoteMode || !fallbackConfig.enabled}
/>
</div>
</div>
</div>
{/* Local Proxy Settings - Only show in Local mode */}
{!isRemoteMode && (
<LocalProxyCard
config={config}
saving={saving}
displayLocalPort={displayLocalPort}
setEditedLocalPort={setEditedLocalPort}
onSaveLocalPort={saveLocalPort}
onSaveConfig={saveConfig}
/>
)}
</div>
</ScrollArea>
{/* Footer */}
<div className="p-4 border-t bg-background">
<Button
variant="outline"
size="sm"
onClick={() => {
fetchConfig();
fetchRawConfig();
}}
disabled={loading || saving}
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</>
);
}
@@ -0,0 +1,66 @@
/**
* Local Proxy Card
* Configuration card for local CLIProxyAPI settings
*/
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import type { CliproxyServerConfig } from '../../types';
interface LocalProxyCardProps {
config: CliproxyServerConfig;
saving: boolean;
displayLocalPort: string;
setEditedLocalPort: (value: string | null) => void;
onSaveLocalPort: () => void;
onSaveConfig: (updates: Partial<CliproxyServerConfig>) => void;
}
export function LocalProxyCard({
config,
saving,
displayLocalPort,
setEditedLocalPort,
onSaveLocalPort,
onSaveConfig,
}: LocalProxyCardProps) {
const localConfig = config.local;
return (
<div className="space-y-3">
<h3 className="text-base font-medium">Local Proxy</h3>
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
{/* Port */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Port</label>
<Input
type="number"
value={displayLocalPort}
onChange={(e) => setEditedLocalPort(e.target.value)}
onBlur={onSaveLocalPort}
placeholder="8317"
className="font-mono max-w-32"
disabled={saving}
/>
</div>
{/* Auto-start */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Auto-start</p>
<p className="text-xs text-muted-foreground">
Start local proxy automatically when needed
</p>
</div>
<Switch
checked={localConfig.auto_start ?? true}
onCheckedChange={(checked) =>
onSaveConfig({ local: { ...localConfig, auto_start: checked } })
}
disabled={saving}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,184 @@
/**
* Remote Proxy Card
* Configuration card for remote CLIProxyAPI settings
*/
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react';
import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types';
interface RemoteProxyCardProps {
config: CliproxyServerConfig;
saving: boolean;
testing: boolean;
testResult: RemoteProxyStatus | null;
displayHost: string;
displayPort: string;
displayAuthToken: string;
setEditedHost: (value: string | null) => void;
setEditedPort: (value: string | null) => void;
setEditedAuthToken: (value: string | null) => void;
onSaveHost: () => void;
onSavePort: () => void;
onSaveAuthToken: () => void;
onSaveConfig: (updates: Partial<CliproxyServerConfig>) => void;
onTestConnection: () => void;
}
export function RemoteProxyCard({
config,
saving,
testing,
testResult,
displayHost,
displayPort,
displayAuthToken,
setEditedHost,
setEditedPort,
setEditedAuthToken,
onSaveHost,
onSavePort,
onSaveAuthToken,
onSaveConfig,
onTestConnection,
}: RemoteProxyCardProps) {
const remoteConfig = config.remote;
// HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL)
const getDefaultPort = (protocol: 'http' | 'https') => (protocol === 'https' ? 443 : 8317);
return (
<div className="space-y-4 p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium flex items-center gap-2">
<Cloud className="w-4 h-4" />
Remote Server Configuration
</h4>
{/* Host */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Host</label>
<Input
value={displayHost}
onChange={(e) => setEditedHost(e.target.value)}
onBlur={onSaveHost}
placeholder="192.168.1.100 or proxy.example.com"
className="font-mono"
disabled={saving}
/>
</div>
{/* Port and Protocol */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-sm text-muted-foreground">
Port{' '}
<span className="text-xs opacity-70">
(default: {getDefaultPort(config.remote.protocol || 'http')})
</span>
</label>
<Input
type="text"
inputMode="numeric"
value={displayPort}
onChange={(e) => setEditedPort(e.target.value.replace(/\D/g, ''))}
onBlur={onSavePort}
placeholder={`Leave empty for ${getDefaultPort(config.remote.protocol || 'http')}`}
className="font-mono"
disabled={saving}
/>
</div>
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Protocol</label>
<Select
value={config.remote.protocol || 'http'}
onValueChange={(value: 'http' | 'https') =>
onSaveConfig({ remote: { ...remoteConfig, protocol: value } })
}
disabled={saving}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Auth Token */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Auth Token (optional)</label>
<Input
type="password"
value={displayAuthToken}
onChange={(e) => setEditedAuthToken(e.target.value)}
onBlur={onSaveAuthToken}
placeholder="Bearer token for authentication"
className="font-mono"
disabled={saving}
/>
</div>
{/* Test Connection */}
<div className="space-y-3 pt-2">
<Button
onClick={onTestConnection}
disabled={testing || !displayHost}
variant="outline"
className="w-full"
>
{testing ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<Wifi className="w-4 h-4 mr-2" />
Test Connection
</>
)}
</Button>
{/* Test Result */}
{testResult && (
<div
className={`p-3 rounded-md ${
testResult.reachable
? 'bg-green-50 border border-green-200 dark:bg-green-900/20 dark:border-green-900/50'
: 'bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-900/50'
}`}
>
<div className="flex items-center gap-2">
{testResult.reachable ? (
<>
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-700 dark:text-green-300">
Connected ({testResult.latencyMs}ms)
</span>
</>
) : (
<>
<WifiOff className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-sm font-medium text-red-700 dark:text-red-300">
{testResult.error || 'Connection failed'}
</span>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,226 @@
/**
* WebSearch Section
* Settings section for WebSearch providers (Gemini, OpenCode, Grok)
*/
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import { RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react';
import { useWebSearchConfig, useRawConfig } from '../../hooks';
import { ProviderCard } from './provider-card';
export default function WebSearchSection() {
const {
config,
status,
loading,
statusLoading,
saving,
error,
success,
geminiModelInput,
setGeminiModelInput,
opencodeModelInput,
setOpencodeModelInput,
geminiModelSaved,
opencodeModelSaved,
fetchConfig,
fetchStatus,
saveConfig,
saveGeminiModel,
saveOpencodeModel,
} = useWebSearchConfig();
const { fetchRawConfig } = useRawConfig();
// Collapsible install hints state
const [showGeminiHint, setShowGeminiHint] = useState(false);
const [showOpencodeHint, setShowOpencodeHint] = useState(false);
const [showGrokHint, setShowGrokHint] = useState(false);
// Load data on mount
useEffect(() => {
fetchConfig();
fetchStatus();
fetchRawConfig();
}, [fetchConfig, fetchStatus, fetchRawConfig]);
const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false;
const isGrokEnabled = config?.providers?.grok?.enabled ?? false;
const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false;
const toggleGemini = () => {
const providers = config?.providers || {};
const currentState = providers.gemini?.enabled ?? false;
saveConfig({
enabled: !currentState || isGrokEnabled || isOpenCodeEnabled,
providers: { ...providers, gemini: { ...providers.gemini, enabled: !currentState } },
});
};
const toggleGrok = () => {
const providers = config?.providers || {};
const currentState = providers.grok?.enabled ?? false;
saveConfig({
enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled,
providers: { ...providers, grok: { ...providers.grok, enabled: !currentState } },
});
};
const toggleOpenCode = () => {
const providers = config?.providers || {};
const currentState = providers.opencode?.enabled ?? false;
saveConfig({
enabled: isGeminiEnabled || isGrokEnabled || !currentState,
providers: { ...providers, opencode: { ...providers.opencode, enabled: !currentState } },
});
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
</div>
</div>
);
}
return (
<>
{/* Toast-style alerts */}
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
}`}
>
{error && (
<Alert variant="destructive" className="py-2 shadow-lg">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span>
</div>
)}
</div>
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground">
CLI-based web search for third-party profiles (gemini, codex, agy, etc.)
</p>
{/* Status Summary */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">
{isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'}
</p>
{statusLoading ? (
<p className="text-sm text-muted-foreground">Checking status...</p>
) : status?.readiness ? (
<p className="text-sm text-muted-foreground">{status.readiness.message}</p>
) : null}
</div>
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
<RefreshCw className={`w-4 h-4 ${statusLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* CLI Providers */}
<div className="space-y-3">
<h3 className="text-base font-medium">Providers</h3>
<ProviderCard
name="gemini"
label="Google Gemini CLI (1000 req/day free)"
badge="FREE"
badgeColor="green"
enabled={isGeminiEnabled}
installed={status?.geminiCli?.installed ?? false}
statusLoading={statusLoading}
saving={saving}
onToggle={toggleGemini}
modelInput={geminiModelInput}
setModelInput={setGeminiModelInput}
onModelBlur={saveGeminiModel}
modelSaved={geminiModelSaved}
modelPlaceholder="gemini-2.5-flash"
showHint={showGeminiHint}
setShowHint={setShowGeminiHint}
installCmd="npm install -g @google/gemini-cli"
docsUrl="https://github.com/google-gemini/gemini-cli"
hintColor="amber"
/>
<ProviderCard
name="opencode"
label="OpenCode (web search via Zen)"
badge="FREE"
badgeColor="green"
enabled={isOpenCodeEnabled}
installed={status?.opencodeCli?.installed ?? false}
statusLoading={statusLoading}
saving={saving}
onToggle={toggleOpenCode}
modelInput={opencodeModelInput}
setModelInput={setOpencodeModelInput}
onModelBlur={saveOpencodeModel}
modelSaved={opencodeModelSaved}
modelPlaceholder="opencode/grok-code"
showHint={showOpencodeHint}
setShowHint={setShowOpencodeHint}
installCmd="curl -fsSL https://opencode.ai/install | bash"
docsUrl="https://github.com/sst/opencode"
hintColor="purple"
/>
<ProviderCard
name="grok"
label="xAI Grok CLI (web + X search)"
badge="GROK_API_KEY"
badgeColor="blue"
enabled={isGrokEnabled}
installed={status?.grokCli?.installed ?? false}
statusLoading={statusLoading}
saving={saving}
onToggle={toggleGrok}
showHint={showGrokHint}
setShowHint={setShowGrokHint}
installCmd="npm install -g @vibe-kit/grok-cli"
docsUrl="https://github.com/superagent-ai/grok-cli"
hintColor="blue"
/>
</div>
</div>
</ScrollArea>
{/* Footer */}
<div className="p-4 border-t bg-background">
<Button
variant="outline"
size="sm"
onClick={() => {
fetchConfig();
fetchRawConfig();
}}
disabled={loading || saving}
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</>
);
}
@@ -0,0 +1,172 @@
/**
* Provider Card Component
* Reusable card for CLI provider configuration in WebSearch section
*/
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react';
export interface ProviderCardProps {
name: string;
label: string;
badge: string;
badgeColor: 'green' | 'blue' | 'purple';
enabled: boolean;
installed: boolean;
statusLoading: boolean;
saving: boolean;
onToggle: () => void;
modelInput?: string;
setModelInput?: (v: string) => void;
onModelBlur?: () => void;
modelSaved?: boolean;
modelPlaceholder?: string;
showHint: boolean;
setShowHint: (v: boolean) => void;
installCmd: string;
docsUrl: string;
hintColor: 'amber' | 'blue' | 'purple';
}
export function ProviderCard({
name,
label,
badge,
badgeColor,
enabled,
installed,
statusLoading,
saving,
onToggle,
modelInput,
setModelInput,
onModelBlur,
modelSaved,
modelPlaceholder,
showHint,
setShowHint,
installCmd,
docsUrl,
hintColor,
}: ProviderCardProps) {
const badgeClass =
badgeColor === 'green'
? 'bg-green-500/10 text-green-600'
: badgeColor === 'blue'
? 'bg-blue-500/10 text-blue-600'
: 'bg-purple-500/10 text-purple-600';
const hintBgClass =
hintColor === 'amber'
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300'
: hintColor === 'blue'
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300';
const hintCodeClass =
hintColor === 'amber'
? 'bg-amber-100 dark:bg-amber-900/40'
: hintColor === 'blue'
? 'bg-blue-100 dark:bg-blue-900/40'
: 'bg-purple-100 dark:bg-purple-900/40';
const hintTextClass =
hintColor === 'amber'
? 'text-amber-600 dark:text-amber-400'
: hintColor === 'blue'
? 'text-blue-600 dark:text-blue-400'
: 'text-purple-600 dark:text-purple-400';
const displayName =
name === 'opencode' ? 'OpenCode' : name.charAt(0).toUpperCase() + name.slice(1);
return (
<div
className={`rounded-lg border transition-colors ${
enabled ? 'border-primary border-l-4' : 'border-border'
}`}
>
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Terminal className={`w-5 h-5 ${enabled ? 'text-primary' : 'text-muted-foreground'}`} />
<div>
<div className="flex items-center gap-2">
<p className="font-mono font-medium">{name}</p>
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${badgeClass}`}>
{badge}
</span>
{installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
installed
</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
not installed
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
</div>
<Switch checked={enabled} onCheckedChange={onToggle} disabled={saving || !installed} />
</div>
{/* Model input when enabled */}
{enabled && modelInput !== undefined && setModelInput && onModelBlur && (
<div className="px-4 pb-4 pt-0">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground whitespace-nowrap">Model:</label>
<Input
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onBlur={onModelBlur}
placeholder={modelPlaceholder}
className="h-8 text-sm font-mono"
disabled={saving}
/>
{modelSaved && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200">
<Check className="w-3.5 h-3.5" />
Saved
</span>
)}
</div>
</div>
)}
{/* Installation hint when not installed */}
{!installed && !statusLoading && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<button
onClick={() => setShowHint(!showHint)}
className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`}
>
{showHint ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
How to install {displayName} CLI
</button>
{showHint && (
<div className={`mt-2 p-3 rounded-md text-sm ${hintBgClass}`}>
<p className="mb-2">
Install globally{' '}
{badge === 'GROK_API_KEY' ? '(requires xAI API key)' : '(FREE tier available)'}:
</p>
<code className={`text-sm px-2 py-1 rounded font-mono block mb-2 ${hintCodeClass}`}>
{installCmd}
</code>
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View documentation
</a>
</div>
)}
</div>
)}
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Settings Context Definition
* Context and types for settings page state management
*/
import { createContext, type Dispatch } from 'react';
import type {
WebSearchConfig,
GlobalEnvConfig,
CliproxyServerConfig,
WebSearchStatus,
RemoteProxyStatus,
} from './types';
// === State ===
export interface SettingsState {
// WebSearch state
webSearchConfig: WebSearchConfig | null;
webSearchStatus: WebSearchStatus | null;
webSearchLoading: boolean;
webSearchStatusLoading: boolean;
webSearchSaving: boolean;
webSearchError: string | null;
webSearchSuccess: boolean;
// GlobalEnv state
globalEnvConfig: GlobalEnvConfig | null;
globalEnvLoading: boolean;
globalEnvSaving: boolean;
globalEnvError: string | null;
globalEnvSuccess: boolean;
// Proxy state
proxyConfig: CliproxyServerConfig | null;
proxyLoading: boolean;
proxySaving: boolean;
proxyError: string | null;
proxySuccess: boolean;
proxyTestResult: RemoteProxyStatus | null;
proxyTesting: boolean;
// Raw config
rawConfig: string | null;
rawConfigLoading: boolean;
}
export const initialSettingsState: SettingsState = {
webSearchConfig: null,
webSearchStatus: null,
webSearchLoading: true,
webSearchStatusLoading: true,
webSearchSaving: false,
webSearchError: null,
webSearchSuccess: false,
globalEnvConfig: null,
globalEnvLoading: true,
globalEnvSaving: false,
globalEnvError: null,
globalEnvSuccess: false,
proxyConfig: null,
proxyLoading: true,
proxySaving: false,
proxyError: null,
proxySuccess: false,
proxyTestResult: null,
proxyTesting: false,
rawConfig: null,
rawConfigLoading: false,
};
// === Actions ===
export type SettingsAction =
| { type: 'SET_WEBSEARCH_CONFIG'; payload: WebSearchConfig | null }
| { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null }
| { type: 'SET_WEBSEARCH_LOADING'; payload: boolean }
| { type: 'SET_WEBSEARCH_STATUS_LOADING'; payload: boolean }
| { type: 'SET_WEBSEARCH_SAVING'; payload: boolean }
| { type: 'SET_WEBSEARCH_ERROR'; payload: string | null }
| { type: 'SET_WEBSEARCH_SUCCESS'; payload: boolean }
| { type: 'SET_GLOBALENV_CONFIG'; payload: GlobalEnvConfig | null }
| { type: 'SET_GLOBALENV_LOADING'; payload: boolean }
| { type: 'SET_GLOBALENV_SAVING'; payload: boolean }
| { type: 'SET_GLOBALENV_ERROR'; payload: string | null }
| { type: 'SET_GLOBALENV_SUCCESS'; payload: boolean }
| { type: 'SET_PROXY_CONFIG'; payload: CliproxyServerConfig | null }
| { type: 'SET_PROXY_LOADING'; payload: boolean }
| { type: 'SET_PROXY_SAVING'; payload: boolean }
| { type: 'SET_PROXY_ERROR'; payload: string | null }
| { type: 'SET_PROXY_SUCCESS'; payload: boolean }
| { type: 'SET_PROXY_TEST_RESULT'; payload: RemoteProxyStatus | null }
| { type: 'SET_PROXY_TESTING'; payload: boolean }
| { type: 'SET_RAW_CONFIG'; payload: string | null }
| { type: 'SET_RAW_CONFIG_LOADING'; payload: boolean };
export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState {
switch (action.type) {
case 'SET_WEBSEARCH_CONFIG':
return { ...state, webSearchConfig: action.payload };
case 'SET_WEBSEARCH_STATUS':
return { ...state, webSearchStatus: action.payload };
case 'SET_WEBSEARCH_LOADING':
return { ...state, webSearchLoading: action.payload };
case 'SET_WEBSEARCH_STATUS_LOADING':
return { ...state, webSearchStatusLoading: action.payload };
case 'SET_WEBSEARCH_SAVING':
return { ...state, webSearchSaving: action.payload };
case 'SET_WEBSEARCH_ERROR':
return { ...state, webSearchError: action.payload };
case 'SET_WEBSEARCH_SUCCESS':
return { ...state, webSearchSuccess: action.payload };
case 'SET_GLOBALENV_CONFIG':
return { ...state, globalEnvConfig: action.payload };
case 'SET_GLOBALENV_LOADING':
return { ...state, globalEnvLoading: action.payload };
case 'SET_GLOBALENV_SAVING':
return { ...state, globalEnvSaving: action.payload };
case 'SET_GLOBALENV_ERROR':
return { ...state, globalEnvError: action.payload };
case 'SET_GLOBALENV_SUCCESS':
return { ...state, globalEnvSuccess: action.payload };
case 'SET_PROXY_CONFIG':
return { ...state, proxyConfig: action.payload };
case 'SET_PROXY_LOADING':
return { ...state, proxyLoading: action.payload };
case 'SET_PROXY_SAVING':
return { ...state, proxySaving: action.payload };
case 'SET_PROXY_ERROR':
return { ...state, proxyError: action.payload };
case 'SET_PROXY_SUCCESS':
return { ...state, proxySuccess: action.payload };
case 'SET_PROXY_TEST_RESULT':
return { ...state, proxyTestResult: action.payload };
case 'SET_PROXY_TESTING':
return { ...state, proxyTesting: action.payload };
case 'SET_RAW_CONFIG':
return { ...state, rawConfig: action.payload };
case 'SET_RAW_CONFIG_LOADING':
return { ...state, rawConfigLoading: action.payload };
default:
return state;
}
}
// === Context ===
export interface SettingsContextValue {
state: SettingsState;
dispatch: Dispatch<SettingsAction>;
}
export const SettingsContext = createContext<SettingsContextValue | null>(null);
+56
View File
@@ -0,0 +1,56 @@
/**
* Settings Page Types
* Type definitions for WebSearch, GlobalEnv, and Proxy configurations
*/
import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client';
// === WebSearch Types ===
export interface ProviderConfig {
enabled?: boolean;
model?: string;
timeout?: number;
}
export interface WebSearchProvidersConfig {
gemini?: ProviderConfig;
grok?: ProviderConfig;
opencode?: ProviderConfig;
}
export interface WebSearchConfig {
enabled: boolean;
providers?: WebSearchProvidersConfig;
}
export interface CliStatus {
installed: boolean;
path: string | null;
version: string | null;
}
export interface WebSearchStatus {
geminiCli: CliStatus;
grokCli: CliStatus;
opencodeCli: CliStatus;
readiness: {
status: 'ready' | 'unavailable';
message: string;
};
}
// === GlobalEnv Types ===
export interface GlobalEnvConfig {
enabled: boolean;
env: Record<string, string>;
}
// === Tab Types ===
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy';
// === Re-exports from api-client ===
export type { CliproxyServerConfig, RemoteProxyStatus };
+271
View File
@@ -0,0 +1,271 @@
/**
* MSW Handlers
* Mock Service Worker handlers for API mocking in tests
*/
import { http, HttpResponse } from 'msw';
// Mock data
export const mockWebSearchConfig = {
enabled: true,
providers: {
gemini: { enabled: true, model: 'gemini-2.5-flash', timeout: 30000 },
grok: { enabled: false, model: 'grok-beta', timeout: 30000 },
opencode: { enabled: true, model: 'opencode/grok-code', timeout: 30000 },
},
};
export const mockWebSearchStatus = {
geminiCli: { installed: true, path: '/usr/local/bin/gemini', version: '1.0.0' },
grokCli: { installed: false, path: null, version: null },
opencodeCli: { installed: true, path: '/usr/local/bin/opencode', version: '2.0.0' },
readiness: { status: 'ready' as const, message: 'WebSearch ready' },
};
export const mockGlobalEnvConfig = {
enabled: true,
env: {
NODE_ENV: 'development',
DEBUG: 'true',
},
};
export const mockProxyConfig = {
remote: {
enabled: true,
host: 'proxy.example.com',
port: 8080,
protocol: 'https' as const,
authToken: 'test-token',
},
fallback: {
enabled: false,
host: '',
port: 0,
protocol: 'http' as const,
},
local: {
enabled: true,
port: 3001,
},
};
export const mockUsageSummary = {
totalTokens: 1500000,
totalInputTokens: 500000,
totalOutputTokens: 1000000,
totalCacheTokens: 200000,
totalCacheCreationTokens: 50000,
totalCacheReadTokens: 150000,
totalCost: 15.5,
tokenBreakdown: {
input: { tokens: 500000, cost: 5.0 },
output: { tokens: 1000000, cost: 10.0 },
cacheCreation: { tokens: 50000, cost: 0.25 },
cacheRead: { tokens: 150000, cost: 0.25 },
},
totalDays: 30,
averageTokensPerDay: 50000,
averageCostPerDay: 0.52,
};
export const mockAuthStatus = {
authStatus: [
{
provider: 'google',
displayName: 'Google',
accounts: [
{
id: 'acc-1',
email: 'user1@gmail.com',
isDefault: true,
lastUsedAt: '2025-01-01T00:00:00Z',
},
{
id: 'acc-2',
email: 'user2@gmail.com',
isDefault: false,
lastUsedAt: '2025-01-02T00:00:00Z',
},
],
},
{
provider: 'github',
displayName: 'GitHub',
accounts: [
{
id: 'acc-3',
email: 'dev@github.com',
isDefault: true,
lastUsedAt: '2025-01-03T00:00:00Z',
},
],
},
],
};
export const mockCliproxyStats = {
accountStats: {
'user1@gmail.com': { successCount: 95, failureCount: 5, lastUsedAt: '2025-01-01T12:00:00Z' },
'user2@gmail.com': { successCount: 45, failureCount: 15, lastUsedAt: '2025-01-02T12:00:00Z' },
'dev@github.com': { successCount: 100, failureCount: 0, lastUsedAt: '2025-01-03T12:00:00Z' },
},
};
// API Handlers
export const handlers = [
// WebSearch endpoints
http.get('/api/websearch', () => {
return HttpResponse.json(mockWebSearchConfig);
}),
http.get('/api/websearch/status', () => {
return HttpResponse.json(mockWebSearchStatus);
}),
http.put('/api/websearch', async ({ request }) => {
const body = (await request.json()) as typeof mockWebSearchConfig;
return HttpResponse.json({ websearch: body });
}),
// GlobalEnv endpoints
http.get('/api/global-env', () => {
return HttpResponse.json(mockGlobalEnvConfig);
}),
http.put('/api/global-env', async ({ request }) => {
const body = (await request.json()) as typeof mockGlobalEnvConfig;
return HttpResponse.json({ config: body });
}),
// Proxy endpoints
http.get('/api/cliproxy-server', () => {
return HttpResponse.json({ data: mockProxyConfig });
}),
http.put('/api/cliproxy-server', async ({ request }) => {
const body = (await request.json()) as Partial<typeof mockProxyConfig>;
return HttpResponse.json({ data: { ...mockProxyConfig, ...body } });
}),
http.post('/api/cliproxy-server/test', () => {
return HttpResponse.json({
data: {
connected: true,
version: '1.0.0',
latency: 50,
},
});
}),
// Usage/Analytics endpoints
http.get('/api/usage/summary', () => {
return HttpResponse.json({ data: mockUsageSummary });
}),
http.get('/api/usage/daily', () => {
return HttpResponse.json({
data: [
{
date: '2025-01-01',
tokens: 50000,
inputTokens: 20000,
outputTokens: 30000,
cacheTokens: 5000,
cost: 0.5,
modelsUsed: 2,
},
{
date: '2025-01-02',
tokens: 60000,
inputTokens: 25000,
outputTokens: 35000,
cacheTokens: 6000,
cost: 0.6,
modelsUsed: 3,
},
],
});
}),
http.get('/api/usage/hourly', () => {
return HttpResponse.json({
data: [
{
hour: '00:00',
tokens: 5000,
inputTokens: 2000,
outputTokens: 3000,
cacheTokens: 500,
cost: 0.05,
modelsUsed: 1,
requests: 10,
},
],
});
}),
http.get('/api/usage/models', () => {
return HttpResponse.json({
data: [
{
model: 'claude-3-opus',
tokens: 100000,
inputTokens: 40000,
outputTokens: 60000,
cacheCreationTokens: 5000,
cacheReadTokens: 10000,
cacheTokens: 15000,
cost: 5.0,
percentage: 60,
costBreakdown: {
input: { tokens: 40000, cost: 2.0 },
output: { tokens: 60000, cost: 3.0 },
cacheCreation: { tokens: 5000, cost: 0.025 },
cacheRead: { tokens: 10000, cost: 0.025 },
},
ioRatio: 1.5,
},
],
});
}),
http.get('/api/usage/sessions', () => {
return HttpResponse.json({
data: {
sessions: [
{
sessionId: 'sess-1',
projectPath: '/home/user/project',
inputTokens: 10000,
outputTokens: 20000,
cost: 0.3,
lastActivity: '2025-01-01T12:00:00Z',
modelsUsed: ['claude-3-opus'],
},
],
total: 1,
limit: 3,
offset: 0,
hasMore: false,
},
});
}),
http.get('/api/usage/status', () => {
return HttpResponse.json({ data: { lastFetch: Date.now(), cacheSize: 1024 } });
}),
http.post('/api/usage/refresh', () => {
return HttpResponse.json({ success: true });
}),
// CLIProxy auth endpoints
http.get('/api/cliproxy/auth', () => {
return HttpResponse.json({ data: mockAuthStatus });
}),
http.get('/api/cliproxy-stats', () => {
return HttpResponse.json({ data: mockCliproxyStats });
}),
];
+105
View File
@@ -0,0 +1,105 @@
/**
* Test Utilities
* Custom render with all required providers for testing React components
*/
/* eslint-disable react-refresh/only-export-components */
import { render, type RenderOptions, type RenderResult } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import type { ReactElement, ReactNode } from 'react';
import { PrivacyProvider } from '../../src/contexts/privacy-context';
import { SettingsProvider } from '../../src/pages/settings/context';
/**
* Create a fresh QueryClient for each test to avoid shared state
*/
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
mutations: {
retry: false,
},
},
});
}
interface AllProvidersProps {
children: ReactNode;
queryClient?: QueryClient;
}
/**
* AllProviders wraps all required context providers for testing
*/
export function AllProviders({ children, queryClient }: AllProvidersProps) {
const client = queryClient ?? createTestQueryClient();
return (
<QueryClientProvider client={client}>
<BrowserRouter>
<PrivacyProvider>{children}</PrivacyProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
/**
* AllProviders with SettingsContext for settings page tests
*/
export function SettingsTestProviders({ children, queryClient }: AllProvidersProps) {
const client = queryClient ?? createTestQueryClient();
return (
<QueryClientProvider client={client}>
<BrowserRouter>
<PrivacyProvider>
<SettingsProvider>{children}</SettingsProvider>
</PrivacyProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient;
withSettingsProvider?: boolean;
}
/**
* Custom render function that wraps components with all necessary providers
*/
function customRender(
ui: ReactElement,
options: CustomRenderOptions = {}
): RenderResult & { queryClient: QueryClient } {
const {
queryClient = createTestQueryClient(),
withSettingsProvider = false,
...renderOptions
} = options;
const Wrapper = withSettingsProvider ? SettingsTestProviders : AllProviders;
const result = render(ui, {
wrapper: ({ children }) => <Wrapper queryClient={queryClient}>{children}</Wrapper>,
...renderOptions,
});
return {
...result,
queryClient,
};
}
// Re-export everything from testing-library
export * from '@testing-library/react';
export { userEvent } from '@testing-library/user-event';
// Override render with our custom version
export { customRender as render };
+44
View File
@@ -0,0 +1,44 @@
/**
* Vitest Setup
* Global test configuration and matchers
*/
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock matchMedia for components that use media queries
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
@@ -0,0 +1,182 @@
/**
* Auth Monitor Utils Tests
* Unit tests for getSuccessRate, cleanEmail, and ACCOUNT_COLORS
*/
import { describe, it, expect } from 'vitest';
import {
getSuccessRate,
cleanEmail,
ACCOUNT_COLORS,
} from '../../../../../../src/components/monitoring/auth-monitor/utils';
describe('getSuccessRate', () => {
describe('edge cases', () => {
it('returns 100 when both success and failure are 0', () => {
expect(getSuccessRate(0, 0)).toBe(100);
});
it('returns 100 when success is non-zero and failure is 0', () => {
expect(getSuccessRate(100, 0)).toBe(100);
expect(getSuccessRate(1, 0)).toBe(100);
expect(getSuccessRate(1000, 0)).toBe(100);
});
it('returns 0 when success is 0 and failure is non-zero', () => {
expect(getSuccessRate(0, 100)).toBe(0);
expect(getSuccessRate(0, 1)).toBe(0);
expect(getSuccessRate(0, 1000)).toBe(0);
});
});
describe('normal cases', () => {
it('calculates 90% correctly', () => {
expect(getSuccessRate(90, 10)).toBe(90);
});
it('calculates 75% correctly', () => {
expect(getSuccessRate(75, 25)).toBe(75);
});
it('calculates 50% correctly', () => {
expect(getSuccessRate(50, 50)).toBe(50);
});
it('calculates 25% correctly', () => {
expect(getSuccessRate(25, 75)).toBe(25);
});
it('calculates 10% correctly', () => {
expect(getSuccessRate(10, 90)).toBe(10);
});
});
describe('rounding', () => {
it('rounds to nearest integer', () => {
expect(getSuccessRate(1, 2)).toBe(33); // 33.33...
expect(getSuccessRate(2, 1)).toBe(67); // 66.66...
});
it('handles 1/3 ratio', () => {
expect(getSuccessRate(1, 3)).toBe(25);
});
it('handles small numbers', () => {
expect(getSuccessRate(1, 1)).toBe(50);
expect(getSuccessRate(1, 9)).toBe(10);
expect(getSuccessRate(9, 1)).toBe(90);
});
});
describe('large numbers', () => {
it('handles large numbers correctly', () => {
expect(getSuccessRate(900000, 100000)).toBe(90);
expect(getSuccessRate(999999, 1)).toBe(100); // rounds up
});
});
});
describe('cleanEmail', () => {
describe('common domains to strip', () => {
it('strips @gmail.com', () => {
expect(cleanEmail('user@gmail.com')).toBe('user');
expect(cleanEmail('john.doe@gmail.com')).toBe('john.doe');
expect(cleanEmail('test123@gmail.com')).toBe('test123');
});
it('strips @yahoo.com', () => {
expect(cleanEmail('user@yahoo.com')).toBe('user');
expect(cleanEmail('john.doe@yahoo.com')).toBe('john.doe');
});
it('strips @hotmail.com', () => {
expect(cleanEmail('user@hotmail.com')).toBe('user');
expect(cleanEmail('john.doe@hotmail.com')).toBe('john.doe');
});
it('strips @outlook.com', () => {
expect(cleanEmail('user@outlook.com')).toBe('user');
expect(cleanEmail('john.doe@outlook.com')).toBe('john.doe');
});
it('strips @icloud.com', () => {
expect(cleanEmail('user@icloud.com')).toBe('user');
expect(cleanEmail('john.doe@icloud.com')).toBe('john.doe');
});
});
describe('case insensitivity', () => {
it('strips uppercase domains', () => {
expect(cleanEmail('user@GMAIL.COM')).toBe('user');
expect(cleanEmail('user@Gmail.Com')).toBe('user');
expect(cleanEmail('user@OUTLOOK.COM')).toBe('user');
});
});
describe('preserves other domains', () => {
it('preserves company domains', () => {
expect(cleanEmail('user@company.com')).toBe('user@company.com');
expect(cleanEmail('admin@acme.org')).toBe('admin@acme.org');
expect(cleanEmail('dev@startup.io')).toBe('dev@startup.io');
});
it('preserves educational domains', () => {
expect(cleanEmail('student@university.edu')).toBe('student@university.edu');
});
it('preserves government domains', () => {
expect(cleanEmail('agent@agency.gov')).toBe('agent@agency.gov');
});
it('preserves subdomains of common providers', () => {
expect(cleanEmail('user@mail.gmail.com')).toBe('user@mail.gmail.com');
});
});
describe('edge cases', () => {
it('handles email-like strings without @ symbol', () => {
expect(cleanEmail('notanemail')).toBe('notanemail');
});
it('handles empty string', () => {
expect(cleanEmail('')).toBe('');
});
it('handles email with numbers', () => {
expect(cleanEmail('user123@gmail.com')).toBe('user123');
});
it('handles email with plus addressing', () => {
expect(cleanEmail('user+tag@gmail.com')).toBe('user+tag');
});
it('handles email with dots', () => {
expect(cleanEmail('first.last@gmail.com')).toBe('first.last');
});
});
});
describe('ACCOUNT_COLORS', () => {
it('is an array with at least 10 colors', () => {
expect(Array.isArray(ACCOUNT_COLORS)).toBe(true);
expect(ACCOUNT_COLORS.length).toBeGreaterThanOrEqual(10);
});
it('contains valid hex color codes', () => {
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
ACCOUNT_COLORS.forEach((color) => {
expect(color).toMatch(hexColorRegex);
});
});
it('all colors are unique', () => {
const uniqueColors = new Set(ACCOUNT_COLORS);
expect(uniqueColors.size).toBe(ACCOUNT_COLORS.length);
});
it('contains expected colors', () => {
expect(ACCOUNT_COLORS).toContain('#1e6091'); // Deep Cerulean
expect(ACCOUNT_COLORS).toContain('#2d8a6e'); // Deep Seaweed
expect(ACCOUNT_COLORS).toContain('#c92a2d'); // Deep Strawberry
});
});
@@ -0,0 +1,112 @@
/**
* Analytics Utils Tests
* Unit tests for formatTokens utility function
*/
import { describe, it, expect } from 'vitest';
import { formatTokens } from '../../../../../src/pages/analytics/utils';
describe('formatTokens', () => {
describe('small numbers (< 1K)', () => {
it('returns exact number for 0', () => {
expect(formatTokens(0)).toBe('0');
});
it('returns exact number for single digits', () => {
expect(formatTokens(1)).toBe('1');
expect(formatTokens(9)).toBe('9');
});
it('returns exact number for double digits', () => {
expect(formatTokens(10)).toBe('10');
expect(formatTokens(99)).toBe('99');
});
it('returns exact number for triple digits', () => {
expect(formatTokens(100)).toBe('100');
expect(formatTokens(999)).toBe('999');
});
});
describe('thousands (1K - 999K)', () => {
it('formats 1000 as 1K', () => {
expect(formatTokens(1000)).toBe('1K');
});
it('formats numbers in thousands with no decimal', () => {
expect(formatTokens(1500)).toBe('2K');
expect(formatTokens(5000)).toBe('5K');
expect(formatTokens(10000)).toBe('10K');
expect(formatTokens(100000)).toBe('100K');
expect(formatTokens(999000)).toBe('999K');
});
it('handles edge cases near 1000', () => {
expect(formatTokens(999)).toBe('999');
expect(formatTokens(1001)).toBe('1K');
});
});
describe('millions (1M - 999M)', () => {
it('formats 1000000 as 1.0M', () => {
expect(formatTokens(1_000_000)).toBe('1.0M');
});
it('formats numbers in millions with one decimal', () => {
expect(formatTokens(1_500_000)).toBe('1.5M');
expect(formatTokens(10_000_000)).toBe('10.0M');
expect(formatTokens(100_000_000)).toBe('100.0M');
expect(formatTokens(999_000_000)).toBe('999.0M');
});
it('handles edge cases near 1M', () => {
expect(formatTokens(999_999)).toBe('1000K');
expect(formatTokens(1_000_001)).toBe('1.0M');
});
it('formats 2.5M correctly', () => {
expect(formatTokens(2_500_000)).toBe('2.5M');
});
});
describe('billions (1B+)', () => {
it('formats 1000000000 as 1.0B', () => {
expect(formatTokens(1_000_000_000)).toBe('1.0B');
});
it('formats numbers in billions with one decimal', () => {
expect(formatTokens(1_500_000_000)).toBe('1.5B');
expect(formatTokens(10_000_000_000)).toBe('10.0B');
expect(formatTokens(100_000_000_000)).toBe('100.0B');
});
it('handles edge cases near 1B', () => {
expect(formatTokens(999_999_999)).toBe('1000.0M');
expect(formatTokens(1_000_000_001)).toBe('1.0B');
});
it('formats 2.7B correctly', () => {
expect(formatTokens(2_700_000_000)).toBe('2.7B');
});
});
describe('negative numbers', () => {
it('handles negative numbers', () => {
// Function doesn't explicitly handle negatives but should not crash
expect(formatTokens(-100)).toBe('-100');
});
});
describe('decimal precision', () => {
it('millions round to one decimal', () => {
expect(formatTokens(1_234_567)).toBe('1.2M');
expect(formatTokens(1_250_000)).toBe('1.3M'); // 1.25 rounds to 1.3
expect(formatTokens(1_240_000)).toBe('1.2M');
});
it('billions round to one decimal', () => {
expect(formatTokens(1_234_567_890)).toBe('1.2B');
expect(formatTokens(1_250_000_000)).toBe('1.3B');
});
});
});
@@ -0,0 +1,489 @@
/**
* Settings Context Hooks Tests
* Unit tests for useSettingsContext and useSettingsActions
*/
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
useSettingsContext,
useSettingsActions,
} from '../../../../../src/pages/settings/hooks/context-hooks';
import { SettingsTestProviders } from '../../../../setup/test-utils';
import type { ReactNode } from 'react';
// Wrapper for hooks that require SettingsContext
function wrapper({ children }: { children: ReactNode }) {
return <SettingsTestProviders>{children}</SettingsTestProviders>;
}
describe('useSettingsContext', () => {
it('throws error when used outside SettingsProvider', () => {
// Suppress console.error for expected error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useSettingsContext());
}).toThrow('useSettingsContext must be used within a SettingsProvider');
consoleSpy.mockRestore();
});
it('returns context when used within SettingsProvider', () => {
const { result } = renderHook(() => useSettingsContext(), { wrapper });
expect(result.current).toBeDefined();
expect(result.current.state).toBeDefined();
expect(result.current.dispatch).toBeDefined();
expect(typeof result.current.dispatch).toBe('function');
});
it('provides initial state', () => {
const { result } = renderHook(() => useSettingsContext(), { wrapper });
expect(result.current.state.webSearchConfig).toBeNull();
expect(result.current.state.webSearchLoading).toBe(true);
expect(result.current.state.globalEnvConfig).toBeNull();
expect(result.current.state.proxyConfig).toBeNull();
});
});
describe('useSettingsActions', () => {
it('returns all action functions', () => {
const { result } = renderHook(() => useSettingsActions(), { wrapper });
// WebSearch actions
expect(typeof result.current.setWebSearchConfig).toBe('function');
expect(typeof result.current.setWebSearchStatus).toBe('function');
expect(typeof result.current.setWebSearchLoading).toBe('function');
expect(typeof result.current.setWebSearchStatusLoading).toBe('function');
expect(typeof result.current.setWebSearchSaving).toBe('function');
expect(typeof result.current.setWebSearchError).toBe('function');
expect(typeof result.current.setWebSearchSuccess).toBe('function');
// GlobalEnv actions
expect(typeof result.current.setGlobalEnvConfig).toBe('function');
expect(typeof result.current.setGlobalEnvLoading).toBe('function');
expect(typeof result.current.setGlobalEnvSaving).toBe('function');
expect(typeof result.current.setGlobalEnvError).toBe('function');
expect(typeof result.current.setGlobalEnvSuccess).toBe('function');
// Proxy actions
expect(typeof result.current.setProxyConfig).toBe('function');
expect(typeof result.current.setProxyLoading).toBe('function');
expect(typeof result.current.setProxySaving).toBe('function');
expect(typeof result.current.setProxyError).toBe('function');
expect(typeof result.current.setProxySuccess).toBe('function');
expect(typeof result.current.setProxyTestResult).toBe('function');
expect(typeof result.current.setProxyTesting).toBe('function');
// Raw config actions
expect(typeof result.current.setRawConfig).toBe('function');
expect(typeof result.current.setRawConfigLoading).toBe('function');
});
describe('WebSearch actions', () => {
it('setWebSearchConfig updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
const config = { enabled: true, providers: { gemini: { enabled: true } } };
act(() => {
result.current.actions.setWebSearchConfig(config);
});
expect(result.current.context.state.webSearchConfig).toEqual(config);
});
it('setWebSearchStatus updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
const status = {
geminiCli: { installed: true, path: '/bin', version: '1.0' },
grokCli: { installed: false, path: null, version: null },
opencodeCli: { installed: false, path: null, version: null },
readiness: { status: 'ready' as const, message: 'Ready' },
};
act(() => {
result.current.actions.setWebSearchStatus(status);
});
expect(result.current.context.state.webSearchStatus).toEqual(status);
});
it('setWebSearchLoading updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setWebSearchLoading(false);
});
expect(result.current.context.state.webSearchLoading).toBe(false);
});
it('setWebSearchStatusLoading updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setWebSearchStatusLoading(false);
});
expect(result.current.context.state.webSearchStatusLoading).toBe(false);
});
it('setWebSearchSaving updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setWebSearchSaving(true);
});
expect(result.current.context.state.webSearchSaving).toBe(true);
});
it('setWebSearchError updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setWebSearchError('Test error');
});
expect(result.current.context.state.webSearchError).toBe('Test error');
});
it('setWebSearchSuccess updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setWebSearchSuccess(true);
});
expect(result.current.context.state.webSearchSuccess).toBe(true);
});
});
describe('GlobalEnv actions', () => {
it('setGlobalEnvConfig updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
const config = { enabled: true, env: { NODE_ENV: 'test' } };
act(() => {
result.current.actions.setGlobalEnvConfig(config);
});
expect(result.current.context.state.globalEnvConfig).toEqual(config);
});
it('setGlobalEnvLoading updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setGlobalEnvLoading(false);
});
expect(result.current.context.state.globalEnvLoading).toBe(false);
});
it('setGlobalEnvSaving updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setGlobalEnvSaving(true);
});
expect(result.current.context.state.globalEnvSaving).toBe(true);
});
it('setGlobalEnvError updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setGlobalEnvError('GlobalEnv error');
});
expect(result.current.context.state.globalEnvError).toBe('GlobalEnv error');
});
it('setGlobalEnvSuccess updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setGlobalEnvSuccess(true);
});
expect(result.current.context.state.globalEnvSuccess).toBe(true);
});
});
describe('Proxy actions', () => {
it('setProxyConfig updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
const config = {
remote: {
enabled: true,
host: 'test.com',
port: 8080,
protocol: 'https' as const,
authToken: '',
},
fallback: { enabled: false, host: '', port: 0, protocol: 'http' as const },
local: { enabled: true, port: 3001 },
};
act(() => {
result.current.actions.setProxyConfig(config);
});
expect(result.current.context.state.proxyConfig).toEqual(config);
});
it('setProxyLoading updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setProxyLoading(false);
});
expect(result.current.context.state.proxyLoading).toBe(false);
});
it('setProxySaving updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setProxySaving(true);
});
expect(result.current.context.state.proxySaving).toBe(true);
});
it('setProxyError updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setProxyError('Proxy connection error');
});
expect(result.current.context.state.proxyError).toBe('Proxy connection error');
});
it('setProxySuccess updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setProxySuccess(true);
});
expect(result.current.context.state.proxySuccess).toBe(true);
});
it('setProxyTesting updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setProxyTesting(true);
});
expect(result.current.context.state.proxyTesting).toBe(true);
});
it('setProxyTestResult updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
const testResult = { connected: true, version: '1.0.0', latency: 25 };
act(() => {
result.current.actions.setProxyTestResult(testResult);
});
expect(result.current.context.state.proxyTestResult).toEqual(testResult);
});
});
describe('Raw config actions', () => {
it('setRawConfig updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setRawConfig('{"key": "value"}');
});
expect(result.current.context.state.rawConfig).toBe('{"key": "value"}');
});
it('setRawConfigLoading updates state', () => {
const { result } = renderHook(
() => {
const context = useSettingsContext();
const actions = useSettingsActions();
return { context, actions };
},
{ wrapper }
);
act(() => {
result.current.actions.setRawConfigLoading(true);
});
expect(result.current.context.state.rawConfigLoading).toBe(true);
});
});
describe('action stability', () => {
it('action functions are stable across renders', () => {
const { result, rerender } = renderHook(() => useSettingsActions(), { wrapper });
const firstRenderActions = result.current;
rerender();
const secondRenderActions = result.current;
// useCallback should return same function references
expect(firstRenderActions.setWebSearchConfig).toBe(secondRenderActions.setWebSearchConfig);
expect(firstRenderActions.setGlobalEnvConfig).toBe(secondRenderActions.setGlobalEnvConfig);
expect(firstRenderActions.setProxyConfig).toBe(secondRenderActions.setProxyConfig);
});
});
});
@@ -0,0 +1,215 @@
/**
* Settings Context Tests
* Unit tests for settingsReducer and initial state
*/
import { describe, it, expect } from 'vitest';
import {
settingsReducer,
initialSettingsState,
type SettingsState,
type SettingsAction,
} from '../../../../../src/pages/settings/settings-context';
describe('settingsReducer', () => {
describe('initial state', () => {
it('has correct default values', () => {
expect(initialSettingsState.webSearchConfig).toBeNull();
expect(initialSettingsState.webSearchStatus).toBeNull();
expect(initialSettingsState.webSearchLoading).toBe(true);
expect(initialSettingsState.webSearchStatusLoading).toBe(true);
expect(initialSettingsState.webSearchSaving).toBe(false);
expect(initialSettingsState.webSearchError).toBeNull();
expect(initialSettingsState.webSearchSuccess).toBe(false);
expect(initialSettingsState.globalEnvConfig).toBeNull();
expect(initialSettingsState.globalEnvLoading).toBe(true);
expect(initialSettingsState.globalEnvSaving).toBe(false);
expect(initialSettingsState.globalEnvError).toBeNull();
expect(initialSettingsState.globalEnvSuccess).toBe(false);
expect(initialSettingsState.proxyConfig).toBeNull();
expect(initialSettingsState.proxyLoading).toBe(true);
expect(initialSettingsState.proxySaving).toBe(false);
expect(initialSettingsState.proxyError).toBeNull();
expect(initialSettingsState.proxySuccess).toBe(false);
expect(initialSettingsState.proxyTestResult).toBeNull();
expect(initialSettingsState.proxyTesting).toBe(false);
expect(initialSettingsState.rawConfig).toBeNull();
expect(initialSettingsState.rawConfigLoading).toBe(false);
});
});
describe('WebSearch actions', () => {
it('SET_WEBSEARCH_CONFIG updates webSearchConfig', () => {
const config = { enabled: true, providers: { gemini: { enabled: true } } };
const action: SettingsAction = { type: 'SET_WEBSEARCH_CONFIG', payload: config };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.webSearchConfig).toEqual(config);
});
it('SET_WEBSEARCH_STATUS updates webSearchStatus', () => {
const status = {
geminiCli: { installed: true, path: '/bin', version: '1.0' },
grokCli: { installed: false, path: null, version: null },
opencodeCli: { installed: false, path: null, version: null },
readiness: { status: 'ready' as const, message: 'Ready' },
};
const action: SettingsAction = { type: 'SET_WEBSEARCH_STATUS', payload: status };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.webSearchStatus).toEqual(status);
});
it('SET_WEBSEARCH_LOADING updates webSearchLoading', () => {
const action: SettingsAction = { type: 'SET_WEBSEARCH_LOADING', payload: false };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.webSearchLoading).toBe(false);
});
it('SET_WEBSEARCH_STATUS_LOADING updates webSearchStatusLoading', () => {
const action: SettingsAction = { type: 'SET_WEBSEARCH_STATUS_LOADING', payload: false };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.webSearchStatusLoading).toBe(false);
});
it('SET_WEBSEARCH_SAVING updates webSearchSaving', () => {
const action: SettingsAction = { type: 'SET_WEBSEARCH_SAVING', payload: true };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.webSearchSaving).toBe(true);
});
it('SET_WEBSEARCH_ERROR updates webSearchError', () => {
const action: SettingsAction = { type: 'SET_WEBSEARCH_ERROR', payload: 'Network error' };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.webSearchError).toBe('Network error');
});
it('SET_WEBSEARCH_SUCCESS updates webSearchSuccess', () => {
const action: SettingsAction = { type: 'SET_WEBSEARCH_SUCCESS', payload: true };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.webSearchSuccess).toBe(true);
});
});
describe('GlobalEnv actions', () => {
it('SET_GLOBALENV_CONFIG updates globalEnvConfig', () => {
const config = { enabled: true, env: { NODE_ENV: 'test' } };
const action: SettingsAction = { type: 'SET_GLOBALENV_CONFIG', payload: config };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.globalEnvConfig).toEqual(config);
});
it('SET_GLOBALENV_LOADING updates globalEnvLoading', () => {
const action: SettingsAction = { type: 'SET_GLOBALENV_LOADING', payload: false };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.globalEnvLoading).toBe(false);
});
it('SET_GLOBALENV_SAVING updates globalEnvSaving', () => {
const action: SettingsAction = { type: 'SET_GLOBALENV_SAVING', payload: true };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.globalEnvSaving).toBe(true);
});
it('SET_GLOBALENV_ERROR updates globalEnvError', () => {
const action: SettingsAction = { type: 'SET_GLOBALENV_ERROR', payload: 'Save failed' };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.globalEnvError).toBe('Save failed');
});
it('SET_GLOBALENV_SUCCESS updates globalEnvSuccess', () => {
const action: SettingsAction = { type: 'SET_GLOBALENV_SUCCESS', payload: true };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.globalEnvSuccess).toBe(true);
});
});
describe('Proxy actions', () => {
it('SET_PROXY_CONFIG updates proxyConfig', () => {
const config = {
remote: {
enabled: true,
host: 'proxy.test',
port: 8080,
protocol: 'https' as const,
authToken: '',
},
fallback: { enabled: false, host: '', port: 0, protocol: 'http' as const },
local: { enabled: true, port: 3001 },
};
const action: SettingsAction = { type: 'SET_PROXY_CONFIG', payload: config };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.proxyConfig).toEqual(config);
});
it('SET_PROXY_LOADING updates proxyLoading', () => {
const action: SettingsAction = { type: 'SET_PROXY_LOADING', payload: false };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.proxyLoading).toBe(false);
});
it('SET_PROXY_SAVING updates proxySaving', () => {
const action: SettingsAction = { type: 'SET_PROXY_SAVING', payload: true };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.proxySaving).toBe(true);
});
it('SET_PROXY_ERROR updates proxyError', () => {
const action: SettingsAction = { type: 'SET_PROXY_ERROR', payload: 'Connection failed' };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.proxyError).toBe('Connection failed');
});
it('SET_PROXY_SUCCESS updates proxySuccess', () => {
const action: SettingsAction = { type: 'SET_PROXY_SUCCESS', payload: true };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.proxySuccess).toBe(true);
});
it('SET_PROXY_TEST_RESULT updates proxyTestResult', () => {
const result = { connected: true, version: '1.0.0', latency: 50 };
const action: SettingsAction = { type: 'SET_PROXY_TEST_RESULT', payload: result };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.proxyTestResult).toEqual(result);
});
it('SET_PROXY_TESTING updates proxyTesting', () => {
const action: SettingsAction = { type: 'SET_PROXY_TESTING', payload: true };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.proxyTesting).toBe(true);
});
});
describe('Raw config actions', () => {
it('SET_RAW_CONFIG updates rawConfig', () => {
const action: SettingsAction = { type: 'SET_RAW_CONFIG', payload: '{"test": true}' };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.rawConfig).toBe('{"test": true}');
});
it('SET_RAW_CONFIG_LOADING updates rawConfigLoading', () => {
const action: SettingsAction = { type: 'SET_RAW_CONFIG_LOADING', payload: true };
const newState = settingsReducer(initialSettingsState, action);
expect(newState.rawConfigLoading).toBe(true);
});
});
describe('immutability', () => {
it('returns new state object without mutating original', () => {
const action: SettingsAction = { type: 'SET_WEBSEARCH_LOADING', payload: false };
const newState = settingsReducer(initialSettingsState, action);
expect(newState).not.toBe(initialSettingsState);
expect(initialSettingsState.webSearchLoading).toBe(true);
});
});
describe('unknown action handling', () => {
it('returns current state for unknown actions', () => {
const state: SettingsState = { ...initialSettingsState, webSearchLoading: false };
// @ts-expect-error Testing unknown action type
const newState = settingsReducer(state, { type: 'UNKNOWN_ACTION', payload: {} });
expect(newState).toBe(state);
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup/vitest-setup.ts'],
globals: true,
css: false,
include: ['tests/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
reportsDirectory: './coverage',
thresholds: {
// 90% coverage thresholds as per user requirement
// Applied to testable pure logic files only
lines: 90,
functions: 90,
branches: 85,
statements: 90,
},
include: [
// Settings - context and pure logic
'src/pages/settings/settings-context.ts',
'src/pages/settings/context.tsx',
'src/pages/settings/hooks/context-hooks.ts',
// Analytics - pure utils
'src/pages/analytics/utils.ts',
// Auth monitor - pure utils
'src/components/monitoring/auth-monitor/utils.ts',
],
exclude: [
'src/**/*.d.ts',
'src/components/ui/**', // shadcn components
'src/main.tsx',
'src/**/index.tsx', // barrel exports and page containers
'src/**/index.ts', // barrel exports
'src/**/types.ts', // type-only files
'**/*.test.{ts,tsx}',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@tests': path.resolve(__dirname, './tests'),
},
},
});