Merge pull request #93 from Wha1eChai/feature/webui

fix: address issue #92 and refactor frontend architecture for production readiness
This commit is contained in:
Badri Narayanan S
2026-01-11 10:58:40 +05:30
committed by GitHub
25 changed files with 2797 additions and 520 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Mark generated CSS file to reduce diff noise
public/css/style.css linguist-generated=true -diff

139
CLAUDE.md
View File

@@ -11,7 +11,7 @@ The proxy translates requests from Anthropic Messages API format → Google Gene
## Commands ## Commands
```bash ```bash
# Install dependencies # Install dependencies (automatically builds CSS via prepare hook)
npm install npm install
# Start server (runs on port 8080) # Start server (runs on port 8080)
@@ -23,8 +23,13 @@ npm start -- --fallback
# Start with debug logging # Start with debug logging
npm start -- --debug npm start -- --debug
# Start with file watching for development # Development mode (file watching)
npm run dev npm run dev # Watch server files only
npm run dev:full # Watch both CSS and server files (recommended for frontend dev)
# CSS build commands
npm run build:css # Build CSS once (minified)
npm run watch:css # Watch CSS files for changes
# Account management # Account management
npm run accounts # Interactive account management npm run accounts # Interactive account management
@@ -113,18 +118,35 @@ src/
``` ```
public/ public/
├── index.html # Main entry point ├── index.html # Main entry point
├── css/
│ ├── style.css # Compiled Tailwind CSS (generated, do not edit)
│ └── src/
│ └── input.css # Tailwind source with @apply directives
├── js/ ├── js/
│ ├── app.js # Main application logic (Alpine.js) │ ├── app.js # Main application logic (Alpine.js)
│ ├── store.js # Global state management │ ├── store.js # Global state management
│ ├── data-store.js # Shared data store (accounts, models, quotas)
│ ├── settings-store.js # Settings management store
│ ├── components/ # UI Components │ ├── components/ # UI Components
│ │ ├── dashboard.js # Real-time stats & charts │ │ ├── dashboard.js # Main dashboard orchestrator
│ │ ├── account-manager.js # Account list & OAuth handling │ │ ├── account-manager.js # Account list & OAuth handling
│ │ ├── logs-viewer.js # Live log streaming │ │ ├── logs-viewer.js # Live log streaming
│ │ ── claude-config.js # CLI settings editor │ │ ── claude-config.js # CLI settings editor
│ │ ├── model-manager.js # Model configuration UI
│ │ ├── server-config.js # Server settings UI
│ │ └── dashboard/ # Dashboard sub-modules
│ │ ├── stats.js # Account statistics calculation
│ │ ├── charts.js # Chart.js visualizations
│ │ └── filters.js # Chart filter state management
│ └── utils/ # Frontend utilities │ └── utils/ # Frontend utilities
│ ├── error-handler.js # Centralized error handling with ErrorHandler.withLoading
│ ├── account-actions.js # Account operations service layer (NEW)
│ ├── validators.js # Input validation
│ └── model-config.js # Model configuration helpers
└── views/ # HTML partials (loaded dynamically) └── views/ # HTML partials (loaded dynamically)
├── dashboard.html ├── dashboard.html
├── accounts.html ├── accounts.html
├── models.html
├── settings.html ├── settings.html
└── logs.html └── logs.html
``` ```
@@ -191,15 +213,29 @@ Each account object in `accounts.json` contains:
**Web Management UI:** **Web Management UI:**
- **Stack**: Vanilla JS + Alpine.js + Tailwind CSS (via CDN) - **Stack**: Vanilla JS + Alpine.js + Tailwind CSS (local build with PostCSS)
- **Build System**:
- Tailwind CLI with JIT compilation
- PostCSS + Autoprefixer
- DaisyUI component library
- Custom `@apply` directives in `public/css/src/input.css`
- Compiled output: `public/css/style.css` (auto-generated on `npm install`)
- **Architecture**: Single Page Application (SPA) with dynamic view loading - **Architecture**: Single Page Application (SPA) with dynamic view loading
- **State Management**: Alpine.store for global state (accounts, settings, logs) - **State Management**:
- Alpine.store for global state (accounts, settings, logs)
- Layered architecture: Service Layer (`account-actions.js`) → Component Layer → UI
- **Features**: - **Features**:
- Real-time dashboard with Chart.js visualization and subscription tier distribution - Real-time dashboard with Chart.js visualization and subscription tier distribution
- Account list with tier badges (Ultra/Pro/Free) and quota progress bars - Account list with tier badges (Ultra/Pro/Free) and quota progress bars
- OAuth flow handling via popup window - OAuth flow handling via popup window
- Live log streaming via Server-Sent Events (SSE) - Live log streaming via Server-Sent Events (SSE)
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`) - Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`)
- Skeleton loading screens for improved perceived performance
- Empty state UX with actionable prompts
- Loading states for all async operations
- **Accessibility**:
- ARIA labels on search inputs and icon buttons
- Keyboard navigation support (Escape to clear search)
- **Security**: Optional password protection via `WEBUI_PASSWORD` env var - **Security**: Optional password protection via `WEBUI_PASSWORD` env var
- **Smart Refresh**: Client-side polling with ±20% jitter and tab visibility detection (3x slower when hidden) - **Smart Refresh**: Client-side polling with ±20% jitter and tab visibility detection (3x slower when hidden)
@@ -263,6 +299,95 @@ Each account object in `accounts.json` contains:
- Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }` - Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }`
- Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats) - Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats)
## Frontend Development
### CSS Build System
**Workflow:**
1. Edit styles in `public/css/src/input.css` (Tailwind source with `@apply` directives)
2. Run `npm run build:css` to compile (or `npm run watch:css` for auto-rebuild)
3. Compiled CSS output: `public/css/style.css` (minified, committed to git)
**Component Styles:**
- Use `@apply` to abstract common Tailwind patterns into reusable classes
- Example: `.btn-action-ghost`, `.status-pill-success`, `.input-search`
- Skeleton loading: `.skeleton`, `.skeleton-stat-card`, `.skeleton-chart`
**When to rebuild:**
- After modifying `public/css/src/input.css`
- After pulling changes that updated CSS source
- Automatically on `npm install` (via `prepare` hook)
### Error Handling Pattern
Use `window.ErrorHandler.withLoading()` for async operations:
```javascript
async myOperation() {
return await window.ErrorHandler.withLoading(async () => {
// Your async code here
const result = await someApiCall();
if (!result.ok) {
throw new Error('Operation failed');
}
return result;
}, this, 'loading', { errorMessage: 'Failed to complete operation' });
}
```
- Automatically manages `this.loading` state
- Shows error toast on failure
- Always resets loading state in `finally` block
### Account Operations Service Layer
Use `window.AccountActions` for account operations instead of direct API calls:
```javascript
// ✅ Good: Use service layer
const result = await window.AccountActions.refreshAccount(email);
if (result.success) {
this.$store.global.showToast('Account refreshed', 'success');
} else {
this.$store.global.showToast(result.error, 'error');
}
// ❌ Bad: Direct API call in component
const response = await fetch(`/api/accounts/${email}/refresh`);
```
**Available methods:**
- `refreshAccount(email)` - Refresh token and quota
- `toggleAccount(email, enabled)` - Enable/disable account (with optimistic update)
- `deleteAccount(email)` - Delete account
- `getFixAccountUrl(email)` - Get OAuth re-auth URL
- `reloadAccounts()` - Reload from disk
- `canDelete(account)` - Check if account is deletable
All methods return `{success: boolean, data?: object, error?: string}`
### Dashboard Modules
Dashboard is split into three modules for maintainability:
1. **stats.js** - Account statistics calculation
- `updateStats(component)` - Computes active/limited/total counts
- Updates subscription tier distribution
2. **charts.js** - Chart.js visualizations
- `initQuotaChart(component)` - Initialize quota distribution pie chart
- `initTrendChart(component)` - Initialize usage trend line chart
- `updateQuotaChart(component)` - Update quota chart data
- `updateTrendChart(component)` - Update trend chart (with concurrency lock)
3. **filters.js** - Filter state management
- `getInitialState()` - Default filter values
- `loadPreferences(component)` - Load from localStorage
- `savePreferences(component)` - Save to localStorage
- Filter types: time range, display mode, family/model selection
Each module is well-documented with JSDoc comments.
## Maintenance ## Maintenance
When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md and the README.md file to keep documentation in sync. When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md and the README.md file to keep documentation in sync.

View File

@@ -458,6 +458,63 @@ By using this software, you acknowledge and accept the following:
--- ---
## Development
### For Developers & Contributors
This project uses a local Tailwind CSS build system. CSS is pre-compiled and included in the repository, so you can run the project immediately after cloning.
#### Quick Start
```bash
git clone https://github.com/badri-s2001/antigravity-claude-proxy.git
cd antigravity-claude-proxy
npm install # Automatically builds CSS via prepare hook
npm start # Start server (no rebuild needed)
```
#### Frontend Development
If you need to modify styles in `public/css/src/input.css`:
```bash
# Option 1: Build once
npm run build:css
# Option 2: Watch for changes (auto-rebuild)
npm run watch:css
# Option 3: Watch both CSS and server (recommended)
npm run dev:full
```
**File Structure:**
- `public/css/src/input.css` - Source CSS with Tailwind `@apply` directives (edit this)
- `public/css/style.css` - Compiled & minified CSS (auto-generated, don't edit)
- `tailwind.config.js` - Tailwind configuration
- `postcss.config.js` - PostCSS configuration
#### Backend-Only Development
If you're only working on backend code and don't need frontend dev tools:
```bash
npm install --production # Skip devDependencies (saves ~20MB)
npm start
```
**Note:** Pre-compiled CSS is committed to the repository, so you don't need to rebuild unless modifying styles.
#### Project Structure
See [CLAUDE.md](./CLAUDE.md) for detailed architecture documentation, including:
- Request flow and module organization
- Frontend architecture (Alpine.js + Tailwind)
- Service layer patterns (`ErrorHandler.withLoading`, `AccountActions`)
- Dashboard module documentation
---
## Credits ## Credits
This project is based on insights and code from: This project is based on insights and code from:

1552
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,12 @@
"public" "public"
], ],
"scripts": { "scripts": {
"build:css": "tailwindcss -i ./public/css/src/input.css -o ./public/css/style.css --minify",
"watch:css": "tailwindcss -i ./public/css/src/input.css -o ./public/css/style.css --watch",
"prepare": "npm run build:css",
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch src/index.js", "dev": "node --watch src/index.js",
"dev:full": "concurrently \"npm run watch:css\" \"npm run dev\"",
"accounts": "node src/cli/accounts.js", "accounts": "node src/cli/accounts.js",
"accounts:add": "node src/cli/accounts.js add", "accounts:add": "node src/cli/accounts.js add",
"accounts:list": "node src/cli/accounts.js list", "accounts:list": "node src/cli/accounts.js list",
@@ -57,5 +61,13 @@
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2" "express": "^4.18.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"daisyui": "^4.12.14",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0"
} }
} }

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

491
public/css/src/input.css Normal file
View File

@@ -0,0 +1,491 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* === Background Layers === */
--color-space-950: #09090b;
--color-space-900: #0f0f11;
--color-space-850: #121214;
--color-space-800: #18181b;
--color-space-border: #27272a;
/* === Neon Accents (Full Saturation) === */
--color-neon-purple: #a855f7;
--color-neon-green: #22c55e;
--color-neon-cyan: #06b6d4;
--color-neon-yellow: #eab308;
--color-neon-red: #ef4444;
/* === Soft Neon (Reduced Saturation for Fills) === */
--color-neon-purple-soft: #9333ea;
--color-neon-green-soft: #16a34a;
--color-neon-cyan-soft: #0891b2;
/* === Text Hierarchy (WCAG AA Compliant) === */
--color-text-primary: #ffffff; /* Emphasis: Titles, Key Numbers */
--color-text-secondary: #d4d4d8; /* Content: Body Text (zinc-300) */
--color-text-tertiary: #a1a1aa; /* Metadata: Timestamps, Labels (zinc-400) */
--color-text-quaternary: #71717a; /* Subtle: Decorative (zinc-500) */
/* === Legacy Aliases (Backward Compatibility) === */
--color-text-main: var(--color-text-secondary);
--color-text-dim: var(--color-text-tertiary);
--color-text-muted: var(--color-text-tertiary);
--color-text-bright: var(--color-text-primary);
/* Gradient Accents */
--color-green-400: #4ade80;
--color-yellow-400: #facc15;
--color-red-400: #f87171;
/* Chart Colors */
--color-chart-1: #a855f7;
--color-chart-2: #c084fc;
--color-chart-3: #e879f9;
--color-chart-4: #d946ef;
--color-chart-5: #22c55e;
--color-chart-6: #4ade80;
--color-chart-7: #86efac;
--color-chart-8: #10b981;
--color-chart-9: #06b6d4;
--color-chart-10: #f59e0b;
--color-chart-11: #ef4444;
--color-chart-12: #ec4899;
--color-chart-13: #8b5cf6;
--color-chart-14: #14b8a6;
--color-chart-15: #f97316;
--color-chart-16: #6366f1;
}
[x-cloak] {
display: none !important;
}
/* Custom Scrollbar */
.custom-scrollbar {
scrollbar-gutter: stable;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(9, 9, 11, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #27272a 0%, #18181b 100%);
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #3f3f46 0%, #27272a 100%);
border-color: rgba(168, 85, 247, 0.3);
}
/* Animations */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out forwards;
}
/* Note: .glass-panel has been deprecated. Use .view-card instead for consistency. */
.nav-item.active {
background: linear-gradient(
90deg,
theme("colors.neon.purple / 15%") 0%,
transparent 100%
);
@apply border-l-4 border-neon-purple text-white;
}
.nav-item {
@apply border-l-4 border-transparent transition-all duration-200;
}
.progress-gradient-success::-webkit-progress-value {
background-image: linear-gradient(
to right,
var(--color-neon-green),
var(--color-green-400)
);
}
.progress-gradient-warning::-webkit-progress-value {
background-image: linear-gradient(
to right,
var(--color-neon-yellow),
var(--color-yellow-400)
);
}
.progress-gradient-error::-webkit-progress-value {
background-image: linear-gradient(
to right,
var(--color-neon-red),
var(--color-red-400)
);
}
/* Dashboard Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
}
/* Tooltip Customization */
.tooltip:before {
@apply bg-space-800 border border-space-border text-gray-200 font-mono text-xs;
}
.tooltip-left:before {
margin-right: 0.5rem;
}
/* -------------------------------------------------------------------------- */
/* Refactored Global Utilities */
/* -------------------------------------------------------------------------- */
/* Standard Layout Constants */
:root {
--view-padding: 2rem; /* 32px - Standard Padding */
--view-gap: 2rem; /* 32px - Standard component gap */
--card-radius: 0.75rem; /* 12px */
}
@media (max-width: 768px) {
:root {
--view-padding: 1rem;
--view-gap: 1.25rem;
}
}
/* Base View Container */
.view-container {
display: flex;
flex-direction: column;
margin-left: auto;
margin-right: auto;
width: 100%;
padding: var(--view-padding);
gap: var(--view-gap);
min-height: calc(100vh - 56px); /* Align with navbar height */
max-width: 1400px;
scrollbar-gutter: stable;
}
/* Specialized container for data-heavy pages (Logs) */
.view-container-full {
@apply w-full animate-fade-in flex flex-col;
padding: var(--view-padding);
gap: var(--view-gap);
min-height: calc(100vh - 56px);
max-width: 100%;
}
/* Centered container for form-heavy pages (Settings/Accounts) */
.view-container-centered {
@apply mx-auto w-full animate-fade-in flex flex-col;
padding: var(--view-padding);
gap: var(--view-gap);
min-height: calc(100vh - 56px);
max-width: 900px; /* Comfortable reading width for forms */
}
/* Standard Section Header */
.view-header {
display: flex;
flex-direction: column;
justify-content: space-between;
margin-bottom: 0.5rem;
gap: 1rem;
}
@media (min-width: 768px) {
.view-header {
flex-direction: row;
align-items: flex-end;
}
}
.view-header-title {
@apply flex flex-col;
}
.view-header-title h2 {
@apply text-2xl font-bold text-white tracking-tight;
}
.view-header-title p {
@apply text-sm text-gray-500 mt-1;
}
.view-header-actions {
@apply flex items-center gap-3;
}
/* Standard Card Panel */
.view-card {
position: relative;
overflow: hidden;
border-radius: var(--card-radius);
padding: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg,
rgba(15, 15, 17, 0.75) 0%,
rgba(18, 18, 20, 0.70) 100%
);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.02) inset,
0 4px 24px rgba(0, 0, 0, 0.4);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.view-card:hover {
border-color: rgba(255, 255, 255, 0.12);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
0 8px 32px rgba(0, 0, 0, 0.5);
}
.view-card-header {
@apply flex items-center justify-between mb-4 pb-4 border-b border-[rgba(39,39,42,0.3)];
}
/* Component Unification */
.standard-table {
@apply table w-full border-separate border-spacing-0;
}
.standard-table thead {
@apply bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border;
}
.standard-table tbody tr {
@apply transition-all duration-200 border-b border-[rgba(39,39,42,0.3)] last:border-0;
}
.standard-table tbody tr:hover {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.05) 50%,
rgba(255, 255, 255, 0.03) 100%
);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* Custom Range Slider - Simplified */
.custom-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
background: var(--color-space-800);
border-radius: 999px;
outline: none;
cursor: pointer;
}
.custom-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--range-color, var(--color-neon-purple));
cursor: pointer;
transition: transform 0.1s ease;
}
.custom-range::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.custom-range::-moz-range-thumb {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: var(--range-color, var(--color-neon-purple));
cursor: pointer;
transition: transform 0.1s ease;
}
.custom-range::-moz-range-thumb:hover {
transform: scale(1.15);
}
/* Color Variants */
.custom-range-purple {
--range-color: var(--color-neon-purple);
}
.custom-range-green {
--range-color: var(--color-neon-green);
}
.custom-range-cyan {
--range-color: var(--color-neon-cyan);
}
.custom-range-yellow {
--range-color: var(--color-neon-yellow);
}
.custom-range-accent {
--range-color: var(--color-neon-cyan);
}
/* -------------------------------------------------------------------------- */
/* Refactored UI Components (Phase 1.2) */
/* -------------------------------------------------------------------------- */
/* Action Buttons */
.btn-action-ghost {
@apply btn btn-xs btn-ghost text-gray-400 hover:text-white transition-colors;
}
.btn-action-ghost-square {
@apply btn btn-xs btn-ghost btn-square text-gray-400 hover:text-white transition-colors;
}
.btn-action-primary {
@apply btn bg-gradient-to-r from-neon-purple to-indigo-600
border-none text-white shadow-lg shadow-neon-purple/20
hover:shadow-neon-purple/40 transition-all;
}
.btn-action-success {
@apply btn btn-xs btn-ghost btn-square text-green-500 hover:bg-green-500/20;
}
.btn-action-danger {
@apply btn btn-xs btn-ghost btn-square text-red-400 hover:bg-red-500/20;
}
.btn-action-neutral {
@apply btn btn-xs btn-ghost btn-square text-gray-500 hover:bg-gray-500/20;
}
/* Status Pills/Badges */
.status-pill {
@apply px-2 py-1 text-[10px] font-mono font-bold uppercase rounded border;
}
.status-pill-purple {
@apply status-pill bg-neon-purple/10 text-neon-purple border-neon-purple/30;
}
.status-pill-ultra {
@apply status-pill bg-yellow-500/10 text-yellow-400 border-yellow-500/30;
}
.status-pill-pro {
@apply status-pill bg-blue-500/10 text-blue-400 border-blue-500/30;
}
.status-pill-free {
@apply status-pill bg-gray-500/10 text-gray-400 border-gray-500/30;
}
.status-pill-success {
@apply status-pill bg-neon-green/10 text-neon-green border-neon-green/30;
}
.status-pill-warning {
@apply status-pill bg-yellow-500/10 text-yellow-400 border-yellow-500/30;
}
.status-pill-error {
@apply status-pill bg-red-500/10 text-red-400 border-red-500/30;
}
/* Input Components */
.input-search {
@apply w-full bg-space-900/50 border border-[rgba(39,39,42,0.5)] text-gray-300
rounded-lg pl-10 pr-4 py-2
focus:outline-none focus:bg-space-800 focus:border-neon-purple/50
hover:border-space-border hover:bg-space-800/80
transition-all placeholder-gray-600/80;
}
.input-search-sm {
@apply input-search h-8 text-xs font-normal;
}
/* -------------------------------------------------------------------------- */
/* Skeleton Loading (Phase 4.1) */
/* -------------------------------------------------------------------------- */
/* Skeleton animation */
@keyframes skeleton-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Base skeleton element */
.skeleton {
@apply bg-gradient-to-r from-space-900/60 via-space-800/40 to-space-900/60;
background-size: 200% 100%;
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 0.375rem;
}
/* Skeleton variants */
.skeleton-text {
@apply skeleton h-4 w-full;
}
.skeleton-text-sm {
@apply skeleton h-3 w-3/4;
}
.skeleton-title {
@apply skeleton h-6 w-1/2;
}
.skeleton-circle {
@apply skeleton rounded-full;
}
.skeleton-stat-card {
@apply skeleton h-32 w-full rounded-xl;
}
.skeleton-chart {
@apply skeleton h-64 w-full rounded-xl;
}
.skeleton-table-row {
@apply skeleton h-12 w-full mb-2;
}

373
public/css/style.css generated

File diff suppressed because one or more lines are too long

View File

@@ -9,57 +9,10 @@
<!-- Libraries --> <!-- Libraries -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<!-- Alpine.js must be deferred so stores register their listeners first --> <!-- Alpine.js must be deferred so stores register their listeners first -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Custom Config --> <!-- Compiled Tailwind CSS (includes DaisyUI) -->
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
sans: ['Inter', 'system-ui', 'sans-serif']
},
colors: {
// Deep Space Palette
space: {
950: 'var(--color-space-950)', // Deep background
900: 'var(--color-space-900)', // Panel background
850: 'var(--color-space-850)', // Hover states
800: 'var(--color-space-800)', // UI elements
border: 'var(--color-space-border)'
},
neon: {
purple: 'var(--color-neon-purple)',
cyan: 'var(--color-neon-cyan)',
green: 'var(--color-neon-green)',
yellow: 'var(--color-neon-yellow)',
red: 'var(--color-neon-red)'
}
}
}
},
daisyui: {
themes: [{
antigravity: {
"primary": "var(--color-neon-purple)", // Neon Purple
"secondary": "var(--color-neon-green)", // Neon Green
"accent": "var(--color-neon-cyan)", // Neon Cyan
"neutral": "var(--color-space-800)", // space-800
"base-100": "var(--color-space-950)", // space-950
"info": "var(--color-neon-cyan)",
"success": "var(--color-neon-green)",
"warning": "var(--color-neon-yellow)",
"error": "var(--color-neon-red)",
}
}]
}
}
</script>
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
</head> </head>
@@ -138,8 +91,8 @@
<div class="h-4 w-px bg-space-border"></div> <div class="h-4 w-px bg-space-border"></div>
<!-- Refresh Button --> <!-- Refresh Button -->
<button class="btn btn-ghost btn-xs btn-square text-gray-400 hover:text-white hover:bg-white/5" <button type="button" class="btn btn-ghost btn-xs btn-square text-gray-400 hover:text-white hover:bg-white/5"
@click="fetchData" :disabled="loading" :title="$store.global.t('refreshData')"> @click="fetchData" :disabled="loading" :title="$store.global.t('refreshData')" aria-label="Refresh data">
<svg class="w-4 h-4" :class="{'animate-spin': loading}" fill="none" stroke="currentColor" <svg class="w-4 h-4" :class="{'animate-spin': loading}" fill="none" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -218,15 +171,15 @@
<!-- Footer Info --> <!-- Footer Info -->
<div class="mt-auto px-6 text-[10px] text-gray-700 font-mono"> <div class="mt-auto px-6 text-[10px] text-gray-700 font-mono">
<div class="flex justify-between"> <div class="flex justify-between">
<span>V 1.0.0</span> <span x-text="'V ' + $store.global.version">V 1.0.0</span>
<a href="https://github.com/badri-s2001/antigravity-claude-proxy" target="_blank" <a href="https://github.com/badri-s2001/antigravity-claude-proxy" target="_blank" rel="noopener noreferrer"
class="hover:text-neon-purple transition-colors">GitHub</a> class="hover:text-neon-purple transition-colors">GitHub</a>
</div> </div>
</div> </div>
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<div class="flex-1 overflow-auto bg-space-950 relative custom-scrollbar" style="scrollbar-gutter: stable;"> <div class="flex-1 overflow-auto bg-space-950 relative custom-scrollbar">
<!-- Views Container --> <!-- Views Container -->
<!-- Dashboard --> <!-- Dashboard -->
@@ -304,12 +257,12 @@
<div class="modal-action mt-6"> <div class="modal-action mt-6">
<form method="dialog"> <form method="dialog">
<button class="btn btn-ghost hover:bg-white/10" x-text="$store.global.t('close')">Close</button> <button type="button" class="btn btn-ghost hover:bg-white/10" x-text="$store.global.t('close')">Close</button>
</form> </form>
</div> </div>
</div> </div>
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button x-text="$store.global.t('close')">close</button> <button type="button" x-text="$store.global.t('close')">close</button>
</form> </form>
</dialog> </dialog>
@@ -354,6 +307,7 @@
<script src="js/config/constants.js"></script> <script src="js/config/constants.js"></script>
<script src="js/utils.js"></script> <script src="js/utils.js"></script>
<script src="js/utils/error-handler.js"></script> <script src="js/utils/error-handler.js"></script>
<script src="js/utils/account-actions.js"></script>
<script src="js/utils/validators.js"></script> <script src="js/utils/validators.js"></script>
<script src="js/utils/model-config.js"></script> <script src="js/utils/model-config.js"></script>
<!-- 2. Alpine Stores (register alpine:init listeners) --> <!-- 2. Alpine Stores (register alpine:init listeners) -->

View File

@@ -7,6 +7,10 @@ window.Components = window.Components || {};
window.Components.accountManager = () => ({ window.Components.accountManager = () => ({
searchQuery: '', searchQuery: '',
deleteTarget: '', deleteTarget: '',
refreshing: false,
toggling: false,
deleting: false,
reloading: false,
get filteredAccounts() { get filteredAccounts() {
const accounts = Alpine.store('data').accounts || []; const accounts = Alpine.store('data').accounts || [];
@@ -36,11 +40,15 @@ window.Components.accountManager = () => ({
}, },
async refreshAccount(email) { async refreshAccount(email) {
const store = Alpine.store('global'); return await window.ErrorHandler.withLoading(async () => {
store.showToast(store.t('refreshingAccount', { email }), 'info'); const store = Alpine.store('global');
const password = store.webuiPassword; store.showToast(store.t('refreshingAccount', { email }), 'info');
try {
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/refresh`, { method: 'POST' }, password); const { response, newPassword } = await window.utils.request(
`/api/accounts/${encodeURIComponent(email)}/refresh`,
{ method: 'POST' },
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword; if (newPassword) store.webuiPassword = newPassword;
const data = await response.json(); const data = await response.json();
@@ -48,11 +56,9 @@ window.Components.accountManager = () => ({
store.showToast(store.t('refreshedAccount', { email }), 'success'); store.showToast(store.t('refreshedAccount', { email }), 'success');
Alpine.store('data').fetchData(); Alpine.store('data').fetchData();
} else { } else {
store.showToast(data.error || store.t('refreshFailed'), 'error'); throw new Error(data.error || store.t('refreshFailed'));
} }
} catch (e) { }, this, 'refreshing', { errorMessage: 'Failed to refresh account' });
store.showToast(store.t('refreshFailed') + ': ' + e.message, 'error');
}
}, },
async toggleAccount(email, enabled) { async toggleAccount(email, enabled) {
@@ -125,11 +131,14 @@ window.Components.accountManager = () => ({
async executeDelete() { async executeDelete() {
const email = this.deleteTarget; const email = this.deleteTarget;
const store = Alpine.store('global'); return await window.ErrorHandler.withLoading(async () => {
const password = store.webuiPassword; const store = Alpine.store('global');
try { const { response, newPassword } = await window.utils.request(
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password); `/api/accounts/${encodeURIComponent(email)}`,
{ method: 'DELETE' },
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword; if (newPassword) store.webuiPassword = newPassword;
const data = await response.json(); const data = await response.json();
@@ -139,18 +148,20 @@ window.Components.accountManager = () => ({
document.getElementById('delete_account_modal').close(); document.getElementById('delete_account_modal').close();
this.deleteTarget = ''; this.deleteTarget = '';
} else { } else {
store.showToast(data.error || store.t('deleteFailed'), 'error'); throw new Error(data.error || store.t('deleteFailed'));
} }
} catch (e) { }, this, 'deleting', { errorMessage: 'Failed to delete account' });
store.showToast(store.t('deleteFailed') + ': ' + e.message, 'error');
}
}, },
async reloadAccounts() { async reloadAccounts() {
const store = Alpine.store('global'); return await window.ErrorHandler.withLoading(async () => {
const password = store.webuiPassword; const store = Alpine.store('global');
try {
const { response, newPassword } = await window.utils.request('/api/accounts/reload', { method: 'POST' }, password); const { response, newPassword } = await window.utils.request(
'/api/accounts/reload',
{ method: 'POST' },
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword; if (newPassword) store.webuiPassword = newPassword;
const data = await response.json(); const data = await response.json();
@@ -158,11 +169,9 @@ window.Components.accountManager = () => ({
store.showToast(store.t('accountsReloaded'), 'success'); store.showToast(store.t('accountsReloaded'), 'success');
Alpine.store('data').fetchData(); Alpine.store('data').fetchData();
} else { } else {
store.showToast(data.error || store.t('reloadFailed'), 'error'); throw new Error(data.error || store.t('reloadFailed'));
} }
} catch (e) { }, this, 'reloading', { errorMessage: 'Failed to reload accounts' });
store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error');
}
}, },
/** /**

View File

@@ -6,6 +6,7 @@ window.Components = window.Components || {};
window.Components.claudeConfig = () => ({ window.Components.claudeConfig = () => ({
config: { env: {} }, config: { env: {} },
configPath: '', // Dynamic path from backend
models: [], models: [],
loading: false, loading: false,
gemini1mSuffix: false, gemini1mSuffix: false,
@@ -76,7 +77,7 @@ window.Components.claudeConfig = () => ({
*/ */
selectModel(field, modelId) { selectModel(field, modelId) {
if (!this.config.env) this.config.env = {}; if (!this.config.env) this.config.env = {};
let finalModelId = modelId; let finalModelId = modelId;
// If 1M mode is enabled and it's a Gemini model, append the suffix // If 1M mode is enabled and it's a Gemini model, append the suffix
if (this.gemini1mSuffix && modelId.toLowerCase().includes('gemini')) { if (this.gemini1mSuffix && modelId.toLowerCase().includes('gemini')) {
@@ -84,7 +85,7 @@ window.Components.claudeConfig = () => ({
finalModelId = finalModelId.trim() + ' [1m]'; finalModelId = finalModelId.trim() + ' [1m]';
} }
} }
this.config.env[field] = finalModelId; this.config.env[field] = finalModelId;
}, },
@@ -97,6 +98,7 @@ window.Components.claudeConfig = () => ({
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json(); const data = await response.json();
this.config = data.config || {}; this.config = data.config || {};
this.configPath = data.path || '~/.claude/settings.json'; // Save dynamic path
if (!this.config.env) this.config.env = {}; if (!this.config.env) this.config.env = {};
// Default MCP CLI to true if not set // Default MCP CLI to true if not set
@@ -106,10 +108,10 @@ window.Components.claudeConfig = () => ({
// Detect existing [1m] suffix state, default to true // Detect existing [1m] suffix state, default to true
const hasExistingSuffix = this.detectGemini1mSuffix(); const hasExistingSuffix = this.detectGemini1mSuffix();
const hasGeminiModels = this.geminiModelFields.some(f => const hasGeminiModels = this.geminiModelFields.some(f =>
this.config.env[f]?.toLowerCase().includes('gemini') this.config.env[f]?.toLowerCase().includes('gemini')
); );
// Default to enabled: if no suffix found but Gemini models exist, apply suffix // Default to enabled: if no suffix found but Gemini models exist, apply suffix
if (!hasExistingSuffix && hasGeminiModels) { if (!hasExistingSuffix && hasGeminiModels) {
this.toggleGemini1mSuffix(true); this.toggleGemini1mSuffix(true);

View File

@@ -1,6 +1,22 @@
/** /**
* Dashboard Charts Module * Dashboard Charts Module
* Handles Chart.js visualizations (quota distribution & usage trend) * 职责:使用 Chart.js 渲染配额分布图和使用趋势图
*
* 调用时机:
* - dashboard 组件 init() 时初始化图表
* - 筛选器变化时更新图表数据
* - $store.data 更新时刷新图表
*
* 图表类型:
* 1. Quota Distribution饼图按模型家族或具体模型显示配额分布
* 2. Usage Trend折线图显示历史使用趋势
*
* 特殊处理:
* - 使用 _trendChartUpdateLock 防止并发更新导致的竞争条件
* - 通过 debounce 优化频繁更新的性能
* - 响应式处理:移动端自动调整图表大小和标签显示
*
* @module DashboardCharts
*/ */
window.DashboardCharts = window.DashboardCharts || {}; window.DashboardCharts = window.DashboardCharts || {};
@@ -133,7 +149,7 @@ window.DashboardCharts.updateCharts = function (component) {
// Safety checks // Safety checks
if (!canvas) { if (!canvas) {
console.warn("quotaChart canvas not found"); console.debug("quotaChart canvas not found");
return; return;
} }
if (typeof Chart === "undefined") { if (typeof Chart === "undefined") {
@@ -141,7 +157,7 @@ window.DashboardCharts.updateCharts = function (component) {
return; return;
} }
if (!isCanvasReady(canvas)) { if (!isCanvasReady(canvas)) {
console.warn("quotaChart canvas not ready, skipping update"); console.debug("quotaChart canvas not ready, skipping update");
return; return;
} }
@@ -158,7 +174,7 @@ window.DashboardCharts.updateCharts = function (component) {
if (!healthByFamily[family]) { if (!healthByFamily[family]) {
healthByFamily[family] = { total: 0, weighted: 0 }; healthByFamily[family] = { total: 0, weighted: 0 };
} }
// Calculate average health from quotaInfo (each entry has { pct }) // Calculate average health from quotaInfo (each entry has { pct })
// Health = average of all account quotas for this model // Health = average of all account quotas for this model
const quotaInfo = row.quotaInfo || []; const quotaInfo = row.quotaInfo || [];
@@ -172,8 +188,8 @@ window.DashboardCharts.updateCharts = function (component) {
}); });
// Update overall health for dashboard display // Update overall health for dashboard display
component.stats.overallHealth = totalModelCount > 0 component.stats.overallHealth = totalModelCount > 0
? Math.round(totalHealthSum / totalModelCount) ? Math.round(totalHealthSum / totalModelCount)
: 0; : 0;
const familyColors = { const familyColors = {
@@ -355,13 +371,13 @@ window.DashboardCharts.updateTrendChart = function (component) {
// Determine if data spans multiple days (for smart label formatting) // Determine if data spans multiple days (for smart label formatting)
const timestamps = sortedEntries.map(([iso]) => new Date(iso)); const timestamps = sortedEntries.map(([iso]) => new Date(iso));
const isMultiDay = timestamps.length > 1 && const isMultiDay = timestamps.length > 1 &&
timestamps[0].toDateString() !== timestamps[timestamps.length - 1].toDateString(); timestamps[0].toDateString() !== timestamps[timestamps.length - 1].toDateString();
// Helper to format X-axis labels based on time range and multi-day status // Helper to format X-axis labels based on time range and multi-day status
const formatLabel = (date) => { const formatLabel = (date) => {
const timeRange = component.timeRange || '24h'; const timeRange = component.timeRange || '24h';
if (timeRange === '7d') { if (timeRange === '7d') {
// Week view: show MM/DD // Week view: show MM/DD
return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' }); return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' });

View File

@@ -1,6 +1,21 @@
/** /**
* Dashboard Filters Module * Dashboard Filters Module
* Handles model/family filter selection and persistence * 职责:管理图表筛选器的状态和持久化
*
* 功能:
* 1. 时间范围筛选1h/6h/24h/7d/all
* 2. 显示模式切换(按家族/按模型)
* 3. 模型/家族多选筛选
* 4. 筛选器状态持久化到 localStorage
*
* 调用时机:
* - 组件初始化时加载用户偏好
* - 筛选器变化时保存并触发图表更新
*
* 持久化键:
* - localStorage['dashboard_chart_prefs']
*
* @module DashboardFilters
*/ */
window.DashboardFilters = window.DashboardFilters || {}; window.DashboardFilters = window.DashboardFilters || {};

View File

@@ -1,12 +1,37 @@
/** /**
* Dashboard Stats Module * Dashboard Stats Module
* Handles account statistics calculation * 职责:根据 Alpine.store('data') 计算账号统计数据
*
* 调用时机:
* - dashboard 组件 init() 时
* - $store.data 更新时(通过 $watch 监听)
*
* 统计维度:
* - total: 启用账号总数(排除禁用账号)
* - active: 有可用配额的账号数
* - limited: 配额受限或失效的账号数
* - subscription: 按订阅级别分类ultra/pro/free
*
* @module DashboardStats
*/ */
window.DashboardStats = window.DashboardStats || {}; window.DashboardStats = window.DashboardStats || {};
/** /**
* Update account statistics (active, limited, total) * 更新账号统计数据
* @param {object} component - Dashboard component instance *
* 统计逻辑:
* 1. 仅统计启用的账号enabled !== false
* 2. 优先统计核心模型Sonnet/Opus/Pro/Flash的配额
* 3. 配额 > 5% 视为 active否则为 limited
* 4. 状态非 'ok' 的账号归为 limited
*
* @param {object} component - Dashboard 组件实例Alpine.js 上下文)
* @param {object} component.stats - 统计数据对象(会被修改)
* @param {number} component.stats.total - 启用账号总数
* @param {number} component.stats.active - 活跃账号数
* @param {number} component.stats.limited - 受限账号数
* @param {object} component.stats.subscription - 订阅级别分布
* @returns {void}
*/ */
window.DashboardStats.updateStats = function(component) { window.DashboardStats.updateStats = function(component) {
const accounts = Alpine.store('data').accounts; const accounts = Alpine.store('data').accounts;

View File

@@ -14,6 +14,7 @@ document.addEventListener('alpine:init', () => {
quotaRows: [], // Filtered view quotaRows: [], // Filtered view
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true) usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
loading: false, loading: false,
initialLoad: true, // Track first load for skeleton screen
connectionStatus: 'connecting', connectionStatus: 'connecting',
lastUpdated: '-', lastUpdated: '-',
@@ -36,7 +37,10 @@ document.addEventListener('alpine:init', () => {
}, },
async fetchData() { async fetchData() {
this.loading = true; // Only show skeleton on initial load, not on refresh
if (this.initialLoad) {
this.loading = true;
}
try { try {
// Get password from global store // Get password from global store
const password = Alpine.store('global').webuiPassword; const password = Alpine.store('global').webuiPassword;
@@ -65,6 +69,11 @@ document.addEventListener('alpine:init', () => {
this.connectionStatus = 'connected'; this.connectionStatus = 'connected';
this.lastUpdated = new Date().toLocaleTimeString(); this.lastUpdated = new Date().toLocaleTimeString();
// Fetch version from config endpoint if not already loaded
if (this.initialLoad) {
this.fetchVersion(password);
}
} catch (error) { } catch (error) {
console.error('Fetch error:', error); console.error('Fetch error:', error);
this.connectionStatus = 'disconnected'; this.connectionStatus = 'disconnected';
@@ -72,6 +81,21 @@ document.addEventListener('alpine:init', () => {
store.showToast(store.t('connectionLost'), 'error'); store.showToast(store.t('connectionLost'), 'error');
} finally { } finally {
this.loading = false; this.loading = false;
this.initialLoad = false; // Mark initial load as complete
}
},
async fetchVersion(password) {
try {
const { response } = await window.utils.request('/api/config', {}, password);
if (response.ok) {
const data = await response.json();
if (data.version) {
Alpine.store('global').version = data.version;
}
}
} catch (error) {
console.warn('Failed to fetch version:', error);
} }
}, },

View File

@@ -79,6 +79,7 @@ document.addEventListener('alpine:init', () => {
linkedAccounts: "Linked Accounts", linkedAccounts: "Linked Accounts",
noSignal: "NO SIGNAL DETECTED", noSignal: "NO SIGNAL DETECTED",
establishingUplink: "ESTABLISHING UPLINK...", establishingUplink: "ESTABLISHING UPLINK...",
goToAccounts: "Go to Accounts",
// Settings - Models // Settings - Models
modelsDesc: "Configure model visibility, pinning, and request routing.", modelsDesc: "Configure model visibility, pinning, and request routing.",
modelsPageDesc: "Real-time quota and status for all available models.", modelsPageDesc: "Real-time quota and status for all available models.",
@@ -97,7 +98,8 @@ document.addEventListener('alpine:init', () => {
opusAlias: "Opus Alias", opusAlias: "Opus Alias",
sonnetAlias: "Sonnet Alias", sonnetAlias: "Sonnet Alias",
haikuAlias: "Haiku Alias", haikuAlias: "Haiku Alias",
claudeSettingsAlert: "Settings below directly modify ~/.claude/settings.json. Restart Claude CLI to apply.", claudeSettingsAlertPrefix: "Settings below directly modify",
claudeSettingsAlertSuffix: "Restart Claude CLI to apply.",
applyToClaude: "Apply to Claude CLI", applyToClaude: "Apply to Claude CLI",
// Settings - Server // Settings - Server
port: "Port", port: "Port",
@@ -117,7 +119,7 @@ document.addEventListener('alpine:init', () => {
sonnetModel: "Sonnet Model", sonnetModel: "Sonnet Model",
haikuModel: "Haiku Model", haikuModel: "Haiku Model",
authToken: "Auth Token", authToken: "Auth Token",
saveConfig: "Save to ~/.claude/settings.json", saveConfig: "Save to Claude CLI settings",
envVar: "Env", envVar: "Env",
// New Keys // New Keys
systemName: "ANTIGRAVITY", systemName: "ANTIGRAVITY",
@@ -326,6 +328,7 @@ document.addEventListener('alpine:init', () => {
linkedAccounts: "已关联账号", linkedAccounts: "已关联账号",
noSignal: "无信号连接", noSignal: "无信号连接",
establishingUplink: "正在建立上行链路...", establishingUplink: "正在建立上行链路...",
goToAccounts: "前往账号管理",
// Settings - Models // Settings - Models
modelsDesc: "配置模型的可见性、置顶和请求路由。", modelsDesc: "配置模型的可见性、置顶和请求路由。",
modelsPageDesc: "所有可用模型的实时配额和状态。", modelsPageDesc: "所有可用模型的实时配额和状态。",
@@ -344,7 +347,8 @@ document.addEventListener('alpine:init', () => {
opusAlias: "Opus 别名", opusAlias: "Opus 别名",
sonnetAlias: "Sonnet 别名", sonnetAlias: "Sonnet 别名",
haikuAlias: "Haiku 别名", haikuAlias: "Haiku 别名",
claudeSettingsAlert: "以下设置直接修改 ~/.claude/settings.json。重启 Claude CLI 生效。", claudeSettingsAlertPrefix: "以下设置直接修改",
claudeSettingsAlertSuffix: "重启 Claude CLI 生效。",
applyToClaude: "应用到 Claude CLI", applyToClaude: "应用到 Claude CLI",
// Settings - Server // Settings - Server
port: "端口", port: "端口",
@@ -364,7 +368,7 @@ document.addEventListener('alpine:init', () => {
sonnetModel: "Sonnet 模型", sonnetModel: "Sonnet 模型",
haikuModel: "Haiku 模型", haikuModel: "Haiku 模型",
authToken: "认证令牌", authToken: "认证令牌",
saveConfig: "保存到 ~/.claude/settings.json", saveConfig: "保存到 Claude CLI 设置",
envVar: "环境变量", envVar: "环境变量",
// New Keys // New Keys
systemName: "ANTIGRAVITY", systemName: "ANTIGRAVITY",

View File

@@ -0,0 +1,199 @@
/**
* Account Actions Service
* 纯业务逻辑层 - 处理账号操作的 HTTP 请求、乐观更新和数据刷新
* 不包含 UI 关注点Toast、Loading、模态框由组件层处理
*/
window.AccountActions = window.AccountActions || {};
/**
* 刷新账号 token 和配额信息
* @param {string} email - 账号邮箱
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
window.AccountActions.refreshAccount = async function(email) {
const store = Alpine.store('global');
try {
const { response, newPassword } = await window.utils.request(
`/api/accounts/${encodeURIComponent(email)}/refresh`,
{ method: 'POST' },
store.webuiPassword
);
if (newPassword) {
store.webuiPassword = newPassword;
}
const data = await response.json();
if (data.status !== 'ok') {
return { success: false, error: data.error || 'Refresh failed' };
}
// 触发数据刷新
await Alpine.store('data').fetchData();
return { success: true, data };
} catch (error) {
return { success: false, error: error.message };
}
};
/**
* 切换账号启用/禁用状态(包含乐观更新和错误回滚)
* @param {string} email - 账号邮箱
* @param {boolean} enabled - 目标状态true=启用, false=禁用)
* @returns {Promise<{success: boolean, rolledBack?: boolean, data?: object, error?: string}>}
*/
window.AccountActions.toggleAccount = async function(email, enabled) {
const store = Alpine.store('global');
const dataStore = Alpine.store('data');
// 乐观更新:立即修改 UI
const account = dataStore.accounts.find(a => a.email === email);
const previousState = account ? account.enabled : !enabled;
if (account) {
account.enabled = enabled;
}
try {
const { response, newPassword } = await window.utils.request(
`/api/accounts/${encodeURIComponent(email)}/toggle`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
},
store.webuiPassword
);
if (newPassword) {
store.webuiPassword = newPassword;
}
const data = await response.json();
if (data.status !== 'ok') {
throw new Error(data.error || 'Toggle failed');
}
// 确认服务器状态
await dataStore.fetchData();
return { success: true, data };
} catch (error) {
// 错误回滚:恢复原状态
if (account) {
account.enabled = previousState;
}
await dataStore.fetchData();
return { success: false, error: error.message, rolledBack: true };
}
};
/**
* 删除账号
* @param {string} email - 账号邮箱
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
window.AccountActions.deleteAccount = async function(email) {
const store = Alpine.store('global');
try {
const { response, newPassword } = await window.utils.request(
`/api/accounts/${encodeURIComponent(email)}`,
{ method: 'DELETE' },
store.webuiPassword
);
if (newPassword) {
store.webuiPassword = newPassword;
}
const data = await response.json();
if (data.status !== 'ok') {
return { success: false, error: data.error || 'Delete failed' };
}
// 触发数据刷新
await Alpine.store('data').fetchData();
return { success: true, data };
} catch (error) {
return { success: false, error: error.message };
}
};
/**
* 获取账号重新认证的 OAuth URL
* 注意:此方法仅返回 URL不打开窗口由组件层决定如何处理
* @param {string} email - 账号邮箱
* @returns {Promise<{success: boolean, url?: string, error?: string}>}
*/
window.AccountActions.getFixAccountUrl = async function(email) {
const store = Alpine.store('global');
try {
const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`;
const { response, newPassword } = await window.utils.request(
urlPath,
{},
store.webuiPassword
);
if (newPassword) {
store.webuiPassword = newPassword;
}
const data = await response.json();
if (data.status !== 'ok') {
return { success: false, error: data.error || 'Failed to get auth URL' };
}
return { success: true, url: data.url };
} catch (error) {
return { success: false, error: error.message };
}
};
/**
* 从磁盘重新加载所有账号配置
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
*/
window.AccountActions.reloadAccounts = async function() {
const store = Alpine.store('global');
try {
const { response, newPassword } = await window.utils.request(
'/api/accounts/reload',
{ method: 'POST' },
store.webuiPassword
);
if (newPassword) {
store.webuiPassword = newPassword;
}
const data = await response.json();
if (data.status !== 'ok') {
return { success: false, error: data.error || 'Reload failed' };
}
// 触发数据刷新
await Alpine.store('data').fetchData();
return { success: true, data };
} catch (error) {
return { success: false, error: error.message };
}
};
/**
* 检查账号是否可以删除
* 来自 Antigravity 数据库的账号source='database')不可删除
* @param {object} account - 账号对象
* @returns {boolean} true 表示可删除
*/
window.AccountActions.canDelete = function(account) {
return account && account.source !== 'database';
};

View File

@@ -105,3 +105,41 @@ window.ErrorHandler.apiCall = async function(apiCall, successMessage = null, err
return result; return result;
}; };
/**
* Execute an async function with automatic loading state management
* @param {Function} asyncFn - Async function to execute
* @param {object} context - Component context (this) that contains the loading state
* @param {string} loadingKey - Name of the loading state property (default: 'loading')
* @param {object} options - Additional options (same as safeAsync)
* @returns {Promise<any>} Result of the function or undefined on error
*
* @example
* // In your Alpine component:
* async refreshAccount(email) {
* return await window.ErrorHandler.withLoading(async () => {
* const response = await window.utils.request(`/api/accounts/${email}/refresh`, { method: 'POST' });
* this.$store.global.showToast('Account refreshed', 'success');
* return response;
* }, this, 'refreshing');
* }
*
* // In HTML:
* // <button @click="refreshAccount(email)" :disabled="refreshing">
* // <i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
* // Refresh
* // </button>
*/
window.ErrorHandler.withLoading = async function(asyncFn, context, loadingKey = 'loading', options = {}) {
// Set loading state to true
context[loadingKey] = true;
try {
// Execute the async function with error handling
const result = await window.ErrorHandler.safeAsync(asyncFn, options.errorMessage, options);
return result;
} finally {
// Always reset loading state, even if there was an error
context[loadingKey] = false;
}
};

View File

@@ -19,15 +19,20 @@
<input type="text" <input type="text"
x-model="searchQuery" x-model="searchQuery"
:placeholder="$store.global.t('searchAccounts')" :placeholder="$store.global.t('searchAccounts')"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-48 pl-9 text-xs h-8" :aria-label="$store.global.t('searchAccounts')"
class="input-search-sm w-48 pl-9 h-8"
@keydown.escape="searchQuery = ''"> @keydown.escape="searchQuery = ''">
<svg class="w-4 h-4 absolute left-3 top-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 absolute left-3 top-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</div> </div>
<button class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8" <button class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8"
@click="reloadAccounts()"> @click="reloadAccounts()"
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> :disabled="reloading">
<svg xmlns="http://www.w3.org/2000/svg"
class="w-3.5 h-3.5 transition-transform"
:class="{ 'animate-spin': reloading }"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
<span x-text="$store.global.t('reload')">Reload</span> <span x-text="$store.global.t('reload')">Reload</span>
@@ -109,17 +114,15 @@
</div> </div>
</td> </td>
<td class="py-4"> <td class="py-4">
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded" <span :class="acc.source === 'oauth' ? 'status-pill-purple' : 'status-pill-free'"
:class="acc.source === 'oauth' ? 'bg-neon-purple/10 text-neon-purple border border-neon-purple/30' : 'bg-gray-500/10 text-gray-400 border border-gray-500/30'"
x-text="acc.source || 'oauth'"> x-text="acc.source || 'oauth'">
</span> </span>
</td> </td>
<td class="py-4"> <td class="py-4">
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded" <span :class="{
:class="{ 'status-pill-ultra': acc.subscription?.tier === 'ultra',
'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30': acc.subscription?.tier === 'ultra', 'status-pill-pro': acc.subscription?.tier === 'pro',
'bg-blue-500/10 text-blue-400 border border-blue-500/30': acc.subscription?.tier === 'pro', 'status-pill-free': !acc.subscription || acc.subscription.tier === 'free' || acc.subscription.tier === 'unknown'
'bg-gray-500/10 text-gray-400 border border-gray-500/30': !acc.subscription || acc.subscription.tier === 'free' || acc.subscription.tier === 'unknown'
}" }"
x-text="(acc.subscription?.tier || 'free').toUpperCase()"> x-text="(acc.subscription?.tier || 'free').toUpperCase()">
</span> </span>
@@ -169,8 +172,12 @@
FIX FIX
</button> </button>
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-white transition-colors" <button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-white transition-colors"
@click="refreshAccount(acc.email)" :title="$store.global.t('refreshData')"> @click="refreshAccount(acc.email)"
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> :disabled="refreshing"
:title="$store.global.t('refreshData')">
<svg class="w-4 h-4 transition-transform"
:class="{ 'animate-spin': refreshing }"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
@@ -200,7 +207,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
<p class="text-sm text-gray-600" x-text="$store.global.t('noSearchResults')">No accounts match your search</p> <p class="text-sm text-gray-600" x-text="$store.global.t('noSearchResults')">No accounts match your search</p>
<button class="btn btn-xs btn-ghost text-gray-500" @click="searchQuery = ''" x-text="$store.global.t('clearSearch')">Clear Search</button> <button class="btn-action-ghost !text-gray-500" @click="searchQuery = ''" x-text="$store.global.t('clearSearch')">Clear Search</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -245,8 +252,10 @@
Cancel Cancel
</button> </button>
<button class="btn bg-red-500 hover:bg-red-600 border-none text-white" <button class="btn bg-red-500 hover:bg-red-600 border-none text-white"
@click="executeDelete()"> @click="executeDelete()"
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> :disabled="deleting"
:class="{ 'loading': deleting }">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="!deleting">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>
<span x-text="$store.global.t('confirmDelete')">Confirm Delete</span> <span x-text="$store.global.t('confirmDelete')">Confirm Delete</span>

View File

@@ -26,8 +26,27 @@
</div> </div>
</div> </div>
<!-- Stats Grid --> <!-- Skeleton Loading (仅在首次加载时显示) -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"> <div x-show="$store.data.initialLoad" class="space-y-6">
<!-- Skeleton Stats Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="skeleton-stat-card"></div>
<div class="skeleton-stat-card"></div>
<div class="skeleton-stat-card"></div>
<div class="skeleton-stat-card"></div>
</div>
<!-- Skeleton Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="skeleton-chart"></div>
<div class="skeleton-chart"></div>
</div>
</div>
<!-- Actual Content (首次加载完成后显示) -->
<div x-show="!$store.data.initialLoad" class="space-y-6">
<!-- Stats Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div <div
class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all duration-300 group relative cursor-pointer" class="stat bg-space-900/40 border border-space-border/30 rounded-xl p-6 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all duration-300 group relative cursor-pointer"
@click="$store.global.activeTab = 'accounts'" @click="$store.global.activeTab = 'accounts'"
@@ -434,4 +453,5 @@
</div> </div>
</div> </div>
</div> </div>
</div> <!-- End of x-show="!$store.data.loading" -->
</div> </div>

View File

@@ -1,5 +1,5 @@
<div x-data="logsViewer" class="view-container h-full flex flex-col"> <div x-data="logsViewer" class="view-container h-full flex flex-col">
<div class="glass-panel rounded-xl overflow-hidden border-space-border flex flex-col flex-1 min-h-0"> <div class="view-card !p-0 flex flex-col flex-1 min-h-0">
<!-- Toolbar --> <!-- Toolbar -->
<div class="bg-space-900 flex flex-wrap gap-y-2 justify-between items-center p-2 px-4 border-b border-space-border select-none min-h-[48px] shrink-0"> <div class="bg-space-900 flex flex-wrap gap-y-2 justify-between items-center p-2 px-4 border-b border-space-border select-none min-h-[48px] shrink-0">
@@ -53,7 +53,7 @@
x-text="$store.global.t('autoScroll')">Auto-Scroll</span> x-text="$store.global.t('autoScroll')">Auto-Scroll</span>
<input type="checkbox" class="toggle toggle-xs toggle-success" x-model="isAutoScroll"> <input type="checkbox" class="toggle toggle-xs toggle-success" x-model="isAutoScroll">
</label> </label>
<button class="btn btn-xs btn-ghost btn-square text-gray-400 hover:text-white" @click="clearLogs" :title="$store.global.t('clearLogs')"> <button class="btn-action-ghost-square" @click="clearLogs" :title="$store.global.t('clearLogs')">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>

View File

@@ -20,9 +20,12 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</div> </div>
<input type="text" :placeholder="$store.global.t('searchPlaceholder')" <input type="text"
class="w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-10 pr-10 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all text-xs placeholder-gray-600" :placeholder="$store.global.t('searchPlaceholder')"
x-model.debounce="$store.data.filters.search" @input="$store.data.computeQuotaRows()"> :aria-label="$store.global.t('searchPlaceholder')"
class="input-search-sm pr-10"
x-model.debounce="$store.data.filters.search"
@input="$store.data.computeQuotaRows()">
<button x-show="$store.data.filters.search" <button x-show="$store.data.filters.search"
x-transition:enter="transition ease-out duration-100" x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-75" x-transition:enter-start="opacity-0 scale-75"
@@ -237,11 +240,28 @@
</div> </div>
</td> </td>
</tr> </tr>
<!-- Empty --> <!-- Empty State -->
<tr x-show="!$store.data.loading && $store.data.quotaRows.length === 0"> <tr x-show="!$store.data.initialLoad && $store.data.quotaRows.length === 0">
<td colspan="6" class="h-64 text-center text-gray-600 font-mono text-xs" <td colspan="6" class="py-16 text-center">
x-text="$store.global.t('noSignal')"> <div class="flex flex-col items-center gap-4 max-w-lg mx-auto">
NO SIGNAL DETECTED <svg class="w-20 h-20 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<h3 class="text-xl font-semibold text-gray-400" x-text="$store.global.t('noSignal')">
NO SIGNAL DETECTED
</h3>
<p class="text-sm text-gray-600 max-w-md leading-relaxed">
No model data available. Add accounts to see available models and quota information.
</p>
<button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-sm gap-2 shadow-lg shadow-neon-purple/20"
@click="$store.global.activeTab = 'accounts'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span x-text="$store.global.t('goToAccounts')">Go to Accounts</span>
</button>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -2,7 +2,7 @@
activeTab: 'ui' activeTab: 'ui'
}" class="view-container"> }" class="view-container">
<!-- Header & Tabs --> <!-- Header & Tabs -->
<div class="glass-panel rounded-xl border border-space-border flex flex-col overflow-hidden"> <div class="view-card !p-0 flex flex-col overflow-hidden">
<div class="bg-space-900/50 border-b border-space-border px-8 pt-8 pb-0 shrink-0"> <div class="bg-space-900/50 border-b border-space-border px-8 pt-8 pb-0 shrink-0">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-white flex items-center gap-2"> <h3 class="text-xl font-bold text-white flex items-center gap-2">
@@ -53,7 +53,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /> d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg> </svg>
<span x-text="$store.global.t('tabServer')">Server</span> <span x-text="$store.global.t('tabServer')">Server</span>
</button> </button>
@@ -197,9 +197,11 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
<span class="text-gray-400" x-text="$store.global.t('claudeSettingsAlert')">Settings below directly <span class="text-gray-400">
modify <code class="text-neon-cyan font-mono">~/.claude/settings.json</code>. Restart Claude CLI <span x-text="$store.global.t('claudeSettingsAlertPrefix')">Settings below directly modify</span>
to apply.</span> <code class="text-neon-cyan font-mono" x-text="configPath">~/.claude/settings.json</code>.
<span x-text="$store.global.t('claudeSettingsAlertSuffix')">Restart Claude CLI to apply.</span>
</span>
</div> </div>
<!-- Base URL --> <!-- Base URL -->
@@ -558,7 +560,7 @@
</div> </div>
<!-- Models List --> <!-- Models List -->
<div class="glass-panel rounded-lg overflow-hidden"> <div class="view-card !p-0">
<table class="standard-table"> <table class="standard-table">
<thead> <thead>
<tr> <tr>
@@ -609,7 +611,7 @@
placeholder="e.g. claude-sonnet-4-5 or gemini-3-flash" placeholder="e.g. claude-sonnet-4-5 or gemini-3-flash"
@keydown.enter="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()" @keydown.enter="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
@keydown.escape="newMapping = config.mapping || ''; stopEditing()"> @keydown.escape="newMapping = config.mapping || ''; stopEditing()">
<button class="btn btn-xs btn-ghost btn-square text-green-500 hover:bg-green-500/20" <button class="btn-action-success"
@click="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()" @click="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
title="Save"> title="Save">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
@@ -618,7 +620,7 @@
stroke-width="2" d="M5 13l4 4L19 7" /> stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
</button> </button>
<button class="btn btn-xs btn-ghost btn-square text-gray-500 hover:bg-gray-500/20" <button class="btn-action-neutral"
@click="newMapping = config.mapping || ''; stopEditing()" @click="newMapping = config.mapping || ''; stopEditing()"
title="Cancel"> title="Cancel">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
@@ -628,7 +630,7 @@
</svg> </svg>
</button> </button>
<button x-show="config.mapping" <button x-show="config.mapping"
class="btn btn-xs btn-ghost btn-square text-red-400 hover:bg-red-500/20" class="btn-action-danger"
@click="await updateModelConfig(modelId, { mapping: '' }); stopEditing()" @click="await updateModelConfig(modelId, { mapping: '' }); stopEditing()"
title="Clear mapping"> title="Clear mapping">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
@@ -700,7 +702,7 @@
<div x-show="activeTab === 'server'" x-data="window.Components.serverConfig()" <div x-show="activeTab === 'server'" x-data="window.Components.serverConfig()"
class="space-y-6 max-w-2xl animate-fade-in pb-10"> class="space-y-6 max-w-2xl animate-fade-in pb-10">
<!-- 🔐 Security Section --> <!-- 🔐 Security Section -->
<div class="glass-panel p-6 border border-neon-yellow/20 bg-neon-yellow/5"> <div class="view-card border-neon-yellow/20 bg-neon-yellow/5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div <div
@@ -741,7 +743,7 @@
<!-- Debug Mode --> <!-- Debug Mode -->
<div <div
class="form-control glass-panel p-4 border border-space-border/50 hover:border-neon-purple/50 transition-all"> class="form-control view-card border-space-border/50 hover:border-neon-purple/50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-200" <span class="text-sm font-medium text-gray-200"
@@ -763,7 +765,7 @@
<!-- Token Cache --> <!-- Token Cache -->
<div <div
class="form-control glass-panel p-4 border border-space-border/50 hover:border-neon-green/50 transition-all"> class="form-control view-card border-space-border/50 hover:border-neon-green/50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-200" <span class="text-sm font-medium text-gray-200"
@@ -787,7 +789,7 @@
</div> </div>
<!-- ▼ Advanced Tuning (Fixed Logic) --> <!-- ▼ Advanced Tuning (Fixed Logic) -->
<div class="glass-panel border border-space-border/50 overflow-hidden"> <div class="view-card !p-0 border-space-border/50">
<div class="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5 transition-colors" <div class="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5 transition-colors"
@click="advancedExpanded = !advancedExpanded"> @click="advancedExpanded = !advancedExpanded">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View File

@@ -13,6 +13,8 @@
*/ */
import path from 'path'; import path from 'path';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import express from 'express'; import express from 'express';
import { getPublicConfig, saveConfig, config } from '../config.js'; import { getPublicConfig, saveConfig, config } from '../config.js';
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js'; import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js';
@@ -21,6 +23,18 @@ import { logger } from '../utils/logger.js';
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js'; import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
import { loadAccounts, saveAccounts } from '../account-manager/storage.js'; import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
// Get package version
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let packageVersion = '1.0.0';
try {
const packageJsonPath = path.join(__dirname, '../../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
packageVersion = packageJson.version;
} catch (error) {
logger.warn('[WebUI] Could not read package.json version, using default');
}
// OAuth state storage (state -> { server, verifier, state, timestamp }) // OAuth state storage (state -> { server, verifier, state, timestamp })
// Maps state ID to active OAuth flow data // Maps state ID to active OAuth flow data
const pendingOAuthFlows = new Map(); const pendingOAuthFlows = new Map();
@@ -254,6 +268,7 @@ export function mountWebUI(app, dirname, accountManager) {
res.json({ res.json({
status: 'ok', status: 'ok',
config: publicConfig, config: publicConfig,
version: packageVersion,
note: 'Edit ~/.config/antigravity-proxy/config.json or use env vars to change these values' note: 'Edit ~/.config/antigravity-proxy/config.json or use env vars to change these values'
}); });
} catch (error) { } catch (error) {

51
tailwind.config.js Normal file
View File

@@ -0,0 +1,51 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./public/**/*.{html,js}" // Simplified: already covers all subdirectories
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
sans: ['Inter', 'system-ui', 'sans-serif']
},
colors: {
space: {
950: '#09090b',
900: '#0f0f11',
850: '#121214',
800: '#18181b',
border: '#27272a'
},
neon: {
purple: '#a855f7',
cyan: '#06b6d4',
green: '#22c55e',
yellow: '#eab308',
red: '#ef4444'
}
}
}
},
plugins: [
require('@tailwindcss/forms'),
require('daisyui')
],
daisyui: {
themes: [{
antigravity: {
"primary": "#a855f7", // neon-purple
"secondary": "#22c55e", // neon-green
"accent": "#06b6d4", // neon-cyan
"neutral": "#18181b", // space-800
"base-100": "#09090b", // space-950
"info": "#06b6d4", // neon-cyan
"success": "#22c55e", // neon-green
"warning": "#eab308", // neon-yellow
"error": "#ef4444", // neon-red
}
}],
logs: false // Disable console logs in production
}
}