mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
> **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 |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
];
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
}),
|
||||
];
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user