Merge pull request #67 from diegosouzapw/release/v0.8.8

release(v0.8.8): analytics redesign, settings restructure, routing expansion, editable rate limits
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-02-17 14:47:56 -03:00
committed by GitHub
32 changed files with 1308 additions and 267 deletions

View File

@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [0.8.8] — 2026-02-17
### Added
- 📊 **Analytics page redesign** — Rebuilt analytics dashboard with Recharts (SVG-based) charts replacing the previous implementation. New layout: stat cards → model usage bar chart → provider breakdown table with success rates and avg latency
- 🎯 **6 global routing strategies** — Expanded from 3 (Fill-First, Round-Robin, P2C) to 6, adding Random, Least-Used, and Cost-Optimized. All strategies now have descriptions and icons in the Settings → Routing tab
- 🔧 **Editable rate limits** — Rate limit defaults (RPM, Min Gap, Max Concurrent) are now editable in Settings → Resilience with save/cancel functionality. Values persist via the resilience API
- 📋 **Policies in Resilience tab** — Moved PoliciesPanel (circuit breaker status + locked identifiers) from Security to Resilience tab for better logical grouping
- 🧠 **Prompt Cache in AI tab** — Relocated CacheStatsCard from Advanced to AI tab alongside Thinking Budget and System Prompt
### Changed
- ♻️ **Settings page restructure** — Reorganized all settings tabs for better UX:
- **Security**: Simplified to Login/Password and IP Access Control only
- **Routing**: Expanded strategy grid with all 6 routing strategies
- **Resilience**: Reordered cards (Provider Profiles → Rate Limiting → Circuit Breakers → Policies & Locked Identifiers)
- **AI**: Now includes Thinking Budget, System Prompt, and Prompt Cache
- **Advanced**: Simplified to only Global Proxy configuration
- 🔄 **Backend routing strategies** — Implemented `random` (Fisher-Yates shuffle), `least-used` (sorted by `lastUsedAt`), `cost-optimized` (sorted by priority ascending), and fixed `p2c` (power-of-two-choices with health scoring) in `auth.ts`
- 🔌 **Resilience API updates** — GET endpoint now merges saved rate limit defaults with constants; PATCH endpoint accepts both `profiles` and `defaults`
- 📊 **Usage page split** — Refactored Usage page into "Request Logs" (with updated icon) and a new dedicated "Limits & Quotas" page
### Fixed
- 🐛 **Provider add error** — Improved error handling for API responses when adding new provider connections, with clear validation feedback
---
## [0.8.5] — 2026-02-17
### Added
@@ -191,6 +219,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
[0.8.8]: https://github.com/diegosouzapw/OmniRoute/compare/v0.8.5...v0.8.8
[0.8.5]: https://github.com/diegosouzapw/OmniRoute/compare/v0.8.0...v0.8.5
[0.8.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/diegosouzapw/OmniRoute/compare/v0.6.0...v0.7.0

View File

@@ -158,7 +158,7 @@ docker compose --profile cli up -d
| Image | Tag | Size | Description |
| ------------------------ | -------- | ------ | --------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release |
| `diegosouzapw/omniroute` | `0.8.5` | ~250MB | Current version |
| `diegosouzapw/omniroute` | `0.8.8` | ~250MB | Current version |
---
@@ -166,19 +166,19 @@ docker compose --profile cli up -d
### 🧠 Core Routing & Intelligence
| Feature | What It Does |
| ------------------------------- | --------------------------------------------------------------------------------- |
| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro seamless |
| 👥 **Multi-Account Support** | Multiple accounts per provider with P2C selection |
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry |
| 🎨 **Custom Combos** | 6 strategies: priority, weighted, round-robin, random, least-used, cost-optimized |
| 🧩 **Custom Models** | Add any model ID to any provider |
| 🌐 **Wildcard Router** | Route `provider/*` patterns to any provider dynamically |
| 🧠 **Thinking Budget** | Passthrough, auto, custom, and adaptive modes for reasoning models |
| 💬 **System Prompt Injection** | Global system prompt applied across all requests |
| 📄 **Responses API** | Full OpenAI Responses API (`/v1/responses`) support for Codex |
| Feature | What It Does |
| ------------------------------- | ------------------------------------------------------------------------------ |
| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro seamless |
| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection |
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry |
| 🎨 **Custom Combos** | 6 strategies: fill-first, round-robin, p2c, random, least-used, cost-optimized |
| 🧩 **Custom Models** | Add any model ID to any provider |
| 🌐 **Wildcard Router** | Route `provider/*` patterns to any provider dynamically |
| 🧠 **Thinking Budget** | Passthrough, auto, custom, and adaptive modes for reasoning models |
| 💬 **System Prompt Injection** | Global system prompt applied across all requests |
| 📄 **Responses API** | Full OpenAI Responses API (`/v1/responses`) support for Codex |
### 🎵 Multi-Modal APIs
@@ -193,26 +193,28 @@ docker compose --profile cli up -d
### 🛡️ Resilience & Security
| Feature | What It Does |
| ------------------------------- | ------------------------------------------------------------ |
| 🔌 **Circuit Breaker** | Auto-open/close per-provider with configurable thresholds |
| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore rate-limit for API key providers |
| 🧠 **Semantic Cache** | Two-tier cache (signature + semantic) reduces cost & latency |
| ⚡ **Request Idempotency** | 5s dedup window for duplicate requests |
| 🔒 **TLS Fingerprint Spoofing** | Bypass TLS-based bot detection via wreq-js |
| 🌐 **IP Filtering** | Allowlist/blocklist for API access control |
| 📋 **Compliance Audit Log** | Tamper-proof request logs with opt-out per API key |
| Feature | What It Does |
| ------------------------------- | ------------------------------------------------------------- |
| 🔌 **Circuit Breaker** | Auto-open/close per-provider with configurable thresholds |
| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore rate-limit for API key providers |
| 🧠 **Semantic Cache** | Two-tier cache (signature + semantic) reduces cost & latency |
| ⚡ **Request Idempotency** | 5s dedup window for duplicate requests |
| 🔒 **TLS Fingerprint Spoofing** | Bypass TLS-based bot detection via wreq-js |
| 🌐 **IP Filtering** | Allowlist/blocklist for API access control |
| 📋 **Compliance Audit Log** | Tamper-proof request logs with opt-out per API key |
| 📊 **Editable Rate Limits** | Configurable RPM, min gap, and max concurrent at system level |
### 📊 Observability & Analytics
| Feature | What It Does |
| ------------------------ | ------------------------------------------------------ |
| 📝 **Request Logging** | Debug mode with full request/response logs |
| 💾 **SQLite Proxy Logs** | Persistent proxy logs survive server restarts |
| 📊 **Usage Analytics** | Track tokens, cost, trends over time |
| 📈 **Progress Tracking** | Opt-in SSE progress events for streaming |
| 🧪 **LLM Evaluations** | Golden set testing with 4 match strategies |
| 🔍 **Request Telemetry** | p50/p95/p99 latency aggregation + X-Request-Id tracing |
| Feature | What It Does |
| ---------------------------- | --------------------------------------------------------------- |
| 📝 **Request Logging** | Debug mode with full request/response logs |
| 💾 **SQLite Proxy Logs** | Persistent proxy logs survive server restarts |
| 📊 **Analytics Dashboard** | Recharts-powered: stat cards, model usage chart, provider table |
| 📈 **Progress Tracking** | Opt-in SSE progress events for streaming |
| 🧪 **LLM Evaluations** | Golden set testing with 4 match strategies |
| 🔍 **Request Telemetry** | p50/p95/p99 latency aggregation + X-Request-Id tracing |
| 📋 **Request Logs + Quotas** | Dedicated pages for log browsing and limits/quotas tracking |
### ☁️ Deployment & Sync
@@ -292,7 +294,7 @@ registerSuite({
## 🛠️ Tech Stack
- **Runtime**: Node.js 20+
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v0.8.5)
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v0.8.8)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs)
- **Streaming**: Server-Sent Events (SSE)
@@ -347,7 +349,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
```bash
# Create a release — npm publish happens automatically
gh release create v0.8.5 --title "v0.8.5" --generate-notes
gh release create v0.8.8 --title "v0.8.8" --generate-notes
```
---

View File

@@ -408,6 +408,9 @@ erDiagram
number stickyRoundRobinLimit
boolean requireLogin
string password_hash
string fallbackStrategy
json rateLimitDefaults
json providerProfiles
}
PROVIDER_CONNECTION {
@@ -750,8 +753,9 @@ Environment variables actively used by code:
3. Request logger writes full headers/body when enabled; treat log directory as sensitive.
4. Cloud behavior depends on correct `NEXT_PUBLIC_BASE_URL` and cloud endpoint reachability.
5. The `open-sse/` directory is published as the `@omniroute/open-sse` **npm workspace package**. Source code imports it via `@omniroute/open-sse/...` (resolved by Next.js `transpilePackages`). File paths in this document still use the directory name `open-sse/` for consistency.
6. Charts in the dashboard use **Recharts** (SVG-based) for accessible, interactive visualizations (bar charts, donut charts).
6. Charts in the dashboard use **Recharts** (SVG-based) for accessible, interactive analytics visualizations (model usage bar charts, provider breakdown tables with success rates).
7. E2E tests use **Playwright** (`tests/e2e/`), run via `npm run test:e2e`. Unit tests use **Node.js test runner** (`tests/unit/`), run via `npm run test:plan3`. Source code under `src/` is **TypeScript** (`.ts`/`.tsx`); the `open-sse/` workspace remains JavaScript (`.js`).
8. Settings page is organized into 5 tabs: Security, Routing (6 global strategies: fill-first, round-robin, p2c, random, least-used, cost-optimized), Resilience (editable rate limits, circuit breaker, policies), AI (thinking budget, system prompt, prompt cache), Advanced (proxy).
## Operational Verification Checklist

View File

@@ -8,24 +8,24 @@
### Backend Core
- [ ] `constants.js` — Substituir `COOLDOWN_MS.transient` por `transientInitial` (5s) + `transientMax` (60s)
- [ ] `constants.js` — Adicionar `PROVIDER_PROFILES` (oauth / apikey) com cooldowns diferenciados
- [ ] `constants.js` — Adicionar `DEFAULT_API_LIMITS` (100 RPM, 200ms minTime)
- [ ] `providerRegistry.js` — Criar helper `getProviderCategory(providerId)``"oauth"` | `"apikey"`
- [ ] `accountFallback.js` — Aceitar `provider` como parâmetro em `checkFallbackError`
- [ ] `accountFallback.js` — Implementar backoff exponencial para 502/503/504 transientes
- [ ] `accountFallback.js` — Calcular cooldown baseado no perfil do provedor
- [ ] `accountFallback.js` — Adicionar helper `getProviderProfile(provider)`
- [x] `constants.ts` — Substituir `COOLDOWN_MS.transient` por `transientInitial` (5s) + `transientMax` (60s)
- [x] `constants.ts` — Adicionar `PROVIDER_PROFILES` (oauth / apikey) com cooldowns diferenciados
- [x] `constants.ts` — Adicionar `DEFAULT_API_LIMITS` (100 RPM, 200ms minTime)
- [x] `providerRegistry.ts` — Criar helper `getProviderCategory(providerId)``"oauth"` | `"apikey"`
- [x] `accountFallback.ts` — Aceitar `provider` como parâmetro em `checkFallbackError`
- [x] `accountFallback.ts` — Implementar backoff exponencial para 502/503/504 transientes
- [x] `accountFallback.ts` — Calcular cooldown baseado no perfil do provedor
- [x] `accountFallback.ts` — Adicionar helper `getProviderProfile(provider)`
### Callers (propagar `provider`)
- [ ] `auth.js``markAccountUnavailable` — Passar `provider` para `checkFallbackError`
- [ ] `combo.js``handleComboChat` / `handleRoundRobinCombo` — Passar `provider` nos erros
- [x] `auth.ts``markAccountUnavailable` — Passar `provider` para `checkFallbackError`
- [x] `combo.ts``handleComboChat` / `handleRoundRobinCombo` — Passar `provider` nos erros
### Testes
- [ ] Atualizar `rate-limit-enhanced.test.mjs` — Teste "transient errors don't increase backoff" → `newBackoffLevel = 1`
- [ ] Criar `error-classification.test.mjs` — Cooldown exponencial 502, perfis OAuth/API, helper `getProviderCategory`
- [x] Atualizar `rate-limit-enhanced.test.mjs` — Teste "transient errors don't increase backoff" → `newBackoffLevel = 1`
- [x] Criar `error-classification.test.mjs` — Cooldown exponencial 502, perfis OAuth/API, helper `getProviderCategory`
---
@@ -33,15 +33,15 @@
### Backend
- [ ] `combo.js` — Importar `getCircuitBreaker` e `CircuitBreakerOpenError`
- [ ] `combo.js``handleComboChat` — Verificar `breaker.canExecute()` antes de cada modelo
- [ ] `combo.js``handleRoundRobinCombo` — Integrar breaker per-model
- [ ] `combo.js` — Marcar `semaphore.markRateLimited` para 502/503/504 (não só 429)
- [ ] `combo.js` — Implementar early exit quando todos os modelos têm breaker OPEN
- [x] `combo.ts` — Importar `getCircuitBreaker` e `CircuitBreakerOpenError`
- [x] `combo.ts``handleComboChat` — Verificar `breaker.canExecute()` antes de cada modelo
- [x] `combo.ts``handleRoundRobinCombo` — Integrar breaker per-model
- [x] `combo.ts` — Marcar `semaphore.markRateLimited` para 502/503/504 (não só 429)
- [x] `combo.ts` — Implementar early exit quando todos os modelos têm breaker OPEN
### Testes
- [ ] Criar `combo-circuit-breaker.test.mjs` — Combo skip breaker OPEN, early exit, semáforo 502
- [x] Criar `combo-circuit-breaker.test.mjs` — Combo skip breaker OPEN, early exit, semáforo 502
---
@@ -49,13 +49,13 @@
### Backend
- [ ] `rateLimitManager.js` — Auto-enable para `apikey` providers com limites elevados
- [ ] `rateLimitManager.js` — Criar limiter com defaults (100 RPM) quando não configurado
- [ ] `auth.js` — Adicionar mutex na `markAccountUnavailable` para evitar marcação paralela
- [x] `rateLimitManager.ts` — Auto-enable para `apikey` providers com limites elevados
- [x] `rateLimitManager.ts` — Criar limiter com defaults (100 RPM) quando não configurado
- [x] `auth.ts` — Adicionar mutex na `markAccountUnavailable` para evitar marcação paralela
### Testes
- [ ] Criar `thundering-herd.test.mjs` — Mutex, auto-enable, limites não restritivos
- [x] Criar `thundering-herd.test.mjs` — Mutex, auto-enable, limites não restritivos
---
@@ -63,30 +63,51 @@
### Settings Page
- [ ] `settings/page.tsx` — Adicionar tab "Resilience" (icon: `health_and_safety`) entre Routing e Pricing
- [x] `settings/page.tsx` — Adicionar tab "Resilience" (icon: `health_and_safety`) entre Routing e Pricing
### Novos Componentes
- [ ] Criar `ResilienceTab.tsx` — Layout com 3 cards
- [ ] Criar `ProviderProfilesCard.tsx` — Toggle OAuth/API Key, inputs para cooldowns
- [ ] Criar `CircuitBreakerCard.tsx` — Status real-time per-provider, auto-refresh 5s, botão reset
- [ ] Criar `RateLimitOverviewCard.tsx` — Tabela providers × accounts × cooldown
- [x] Criar `ResilienceTab.tsx` — Layout com 4 cards (Provider Profiles → Rate Limiting → Circuit Breakers → Policies)
- [x] Criar `ProviderProfilesCard.tsx` — Toggle OAuth/API Key, inputs para cooldowns
- [x] Criar `CircuitBreakerCard.tsx` — Status real-time per-provider, auto-refresh 5s, botão reset
- [x] Criar `RateLimitOverviewCard.tsx` — Tabela providers × accounts × cooldown**agora editável com RPM, Min Gap, Max Concurrent**
### API Routes
- [ ] Criar `api/resilience/route.ts` — GET (estado completo) + PATCH (salvar perfis)
- [ ] Criar `api/resilience/reset/route.ts` — POST (resetar breakers + cooldowns)
- [x] Criar `api/resilience/route.ts` — GET (estado completo + defaults mesclados) + PATCH (salvar perfis + defaults)
- [x] Criar `api/resilience/reset/route.ts` — POST (resetar breakers + cooldowns)
### Migração
- [ ] Avaliar se `PoliciesPanel.tsx` pode ser removido ou simplificado após nova aba
- [x] `PoliciesPanel.tsx` movido de Security para Resilience tab
---
## Fase 5 — Settings Page Restructure (v0.9.0)
### Tab Reorganization
- [x] **Security** — Simplificado para Login/Password + IP Access Control
- [x] **Routing** — Expandido para 6 estratégias globais com descrições
- [x] **Resilience** — Reordenado: Provider Profiles → Rate Limiting (editável) → Circuit Breakers → Policies
- [x] **AI** — Thinking Budget + System Prompt + Prompt Cache (movido do Advanced)
- [x] **Advanced** — Simplificado para apenas Global Proxy
### Backend Routing Strategies
- [x] `auth.ts` — Implementar `random` (Fisher-Yates shuffle)
- [x] `auth.ts` — Implementar `least-used` (sorted by lastUsedAt)
- [x] `auth.ts` — Implementar `cost-optimized` (sorted by priority)
- [x] `auth.ts` — Corrigir `p2c` (power-of-two-choices com health scoring)
- [x] `settings.ts` — Expandir tipo `fallbackStrategy` para 6 valores
---
## Verificação Final
- [ ] Rodar todos os testes unitários: `node --test tests/unit/*.test.mjs`
- [ ] Build do Next.js: `npm run build`
- [ ] Verificar aba Resilience no browser
- [ ] Testar persistência dos perfis (salvar → reload)
- [ ] Testar Reset All Breakers
- [x] Rodar todos os testes unitários: `node --test tests/unit/*.test.mjs`
- [x] Build do Next.js: `npm run build`
- [x] Verificar aba Resilience no browser
- [x] Testar persistência dos perfis (salvar → reload)
- [x] Testar Reset All Breakers
- [x] Verificar todas as 5 tabs reestruturadas

View File

@@ -513,6 +513,9 @@ Configure via **Dashboard → Settings → Routing**.
| **Fill First** | Uses accounts in priority order — primary account handles all requests until unavailable |
| **Round Robin** | Cycles through all accounts with a configurable sticky limit (default: 3 calls per account) |
| **P2C (Power of Two Choices)** | Picks 2 random accounts and routes to the healthier one — balances load with awareness of health |
| **Random** | Randomly selects an account for each request using Fisher-Yates shuffle |
| **Least Used** | Routes to the account with the oldest `lastUsedAt` timestamp, distributing traffic evenly |
| **Cost Optimized** | Routes to the account with the lowest priority value, optimizing for lowest-cost providers |
#### Wildcard Model Aliases
@@ -542,25 +545,47 @@ Chain: production-fallback
Configure via **Dashboard → Settings → Resilience**.
OmniRoute implements provider-level resilience with three components:
OmniRoute implements provider-level resilience with four components:
1. **Circuit Breaker** — Tracks failures per provider and automatically opens the circuit when a threshold is reached:
- **CLOSED** (Healthy) — Requests flow normally
- **OPEN** — Provider is temporarily blocked after repeated failures
- **HALF_OPEN** — Testing if provider has recovered
2. **Provider Profiles** — Per-provider configuration for:
1. **Provider Profiles** — Per-provider configuration for:
- Failure threshold (how many failures before opening)
- Cooldown duration
- Rate limit detection sensitivity
- Exponential backoff parameters
3. **Rate Limit Auto-Detection** — Monitors `429` and `Retry-After` headers to proactively avoid hitting provider rate limits.
2. **Editable Rate Limits** — System-level defaults configurable in the dashboard:
- **Requests Per Minute (RPM)** — Maximum requests per minute per account
- **Min Time Between Requests** — Minimum gap in milliseconds between requests
- **Max Concurrent Requests** — Maximum simultaneous requests per account
- Click **Edit** to modify, then **Save** or **Cancel**. Values persist via the resilience API.
3. **Circuit Breaker** — Tracks failures per provider and automatically opens the circuit when a threshold is reached:
- **CLOSED** (Healthy) — Requests flow normally
- **OPEN** — Provider is temporarily blocked after repeated failures
- **HALF_OPEN** — Testing if provider has recovered
4. **Policies & Locked Identifiers** — Shows circuit breaker status and locked identifiers with force-unlock capability.
5. **Rate Limit Auto-Detection** — Monitors `429` and `Retry-After` headers to proactively avoid hitting provider rate limits.
**Pro Tip:** Use **Reset All** button to clear all circuit breakers and cooldowns when a provider recovers from an outage.
---
### Settings Dashboard
The settings page is organized into 5 tabs for easy navigation:
| Tab | Contents |
| -------------- | ---------------------------------------------------------------------------------------------- |
| **Security** | Login/Password settings and IP Access Control (allowlist/blocklist) |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Provider profiles, editable rate limits, circuit breaker status, policies & locked identifiers |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5) |
---
### Costs & Budget Management
Access via **Dashboard → Costs**.

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "0.8.5",
"version": "0.8.8",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {

View File

@@ -29,6 +29,7 @@ export default function CLIToolsPageClient({ machineId }) {
const [cloudEnabled, setCloudEnabled] = useState(false);
const [apiKeys, setApiKeys] = useState([]);
const [toolStatuses, setToolStatuses] = useState({});
const [statusesLoaded, setStatusesLoaded] = useState(false);
useEffect(() => {
fetchConnections();
@@ -70,6 +71,8 @@ export default function CLIToolsPageClient({ machineId }) {
}
} catch (error) {
console.log("Error fetching CLI tool statuses:", error);
} finally {
setStatusesLoaded(true);
}
};
@@ -144,7 +147,7 @@ export default function CLIToolsPageClient({ machineId }) {
return "http://localhost:20128";
};
if (loading) {
if (loading || !statusesLoaded) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
@@ -167,6 +170,7 @@ export default function CLIToolsPageClient({ machineId }) {
baseUrl: getBaseUrl(),
apiKeys,
batchStatus: toolStatuses[toolId] || null,
lastConfiguredAt: toolStatuses[toolId]?.lastConfiguredAt || null,
};
switch (toolId) {

View File

@@ -19,6 +19,7 @@ export default function ClaudeToolCard({
apiKeys,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [claudeStatus, setClaudeStatus] = useState(null);
const [checkingClaude, setCheckingClaude] = useState(false);
@@ -277,6 +278,7 @@ export default function ClaudeToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>

View File

@@ -4,13 +4,41 @@
* Shared status badge for CLI tool cards.
* Shows the effective config/installation status using batch data,
* so badges are visible even when cards are collapsed.
* Optionally shows last-configured relative timestamp.
*/
export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) {
function formatRelativeTime(isoDate: string): string {
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;
if (diffMs < 0) return "just now";
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
export default function CliStatusBadge({
effectiveConfigStatus,
batchStatus,
lastConfiguredAt = null,
}) {
// Determine badge from effectiveConfigStatus or batchStatus
const status = effectiveConfigStatus || batchStatus?.configStatus || null;
if (!status) return null;
const badges = {
configured: {
dotClass: "bg-green-500",
@@ -39,14 +67,32 @@ export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) {
},
};
const badge = badges[status] || badges.unknown;
const badge = status ? badges[status] || badges.unknown : null;
return (
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full ${badge.badgeClass}`}
>
<span className={`size-1.5 rounded-full ${badge.dotClass}`} />
{badge.text}
</span>
<>
{badge && (
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full ${badge.badgeClass}`}
>
<span className={`size-1.5 rounded-full ${badge.dotClass}`} />
{badge.text}
</span>
)}
{lastConfiguredAt ? (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted"
title={`Last saved: ${new Date(lastConfiguredAt).toLocaleString()}`}
>
<span className="material-symbols-outlined text-[12px]">schedule</span>
{formatRelativeTime(lastConfiguredAt)}
</span>
) : status && status !== "not_installed" ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted">
<span className="material-symbols-outlined text-[12px]">schedule</span>
Never
</span>
) : null}
</>
);
}

View File

@@ -17,6 +17,7 @@ export default function ClineToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [clineStatus, setClineStatus] = useState(null);
const [checkingCline, setCheckingCline] = useState(false);
@@ -236,6 +237,7 @@ export default function ClineToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
@@ -463,11 +465,14 @@ export default function ClineToolCard({
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Cline Manual Configuration"
{...{onApply: handleManualConfig, currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
baseUrl: customBaseUrl || baseUrl,
}} as any}
{...({
onApply: handleManualConfig,
currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
baseUrl: customBaseUrl || baseUrl,
},
} as any)}
/>
)}
</Card>

View File

@@ -14,6 +14,7 @@ export default function CodexToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [codexStatus, setCodexStatus] = useState(null);
const [checkingCodex, setCheckingCodex] = useState(false);
@@ -337,6 +338,7 @@ wire_api = "responses"
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>

View File

@@ -17,6 +17,7 @@ export default function DroidToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [droidStatus, setDroidStatus] = useState(null);
const [checkingDroid, setCheckingDroid] = useState(false);
@@ -270,6 +271,7 @@ export default function DroidToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>

View File

@@ -17,6 +17,7 @@ export default function KiloToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [kiloStatus, setKiloStatus] = useState(null);
const [checkingKilo, setCheckingKilo] = useState(false);
@@ -222,6 +223,7 @@ export default function KiloToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
@@ -448,11 +450,14 @@ export default function KiloToolCard({
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Kilo Code Manual Configuration"
{...{onApply: handleManualConfig, currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
baseUrl: customBaseUrl || baseUrl,
}} as any}
{...({
onApply: handleManualConfig,
currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
baseUrl: customBaseUrl || baseUrl,
},
} as any)}
/>
)}
</Card>

View File

@@ -17,6 +17,7 @@ export default function OpenClawToolCard({
activeProviders,
cloudEnabled,
batchStatus,
lastConfiguredAt,
}) {
const [openclawStatus, setOpenclawStatus] = useState(null);
const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
@@ -274,6 +275,7 @@ export default function OpenClawToolCard({
<CliStatusBadge
effectiveConfigStatus={effectiveConfigStatus}
batchStatus={batchStatus}
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
// ─── State colors and labels ──────────────────────────────────────────────
const STATE_STYLES = {
@@ -28,6 +29,12 @@ const STATE_STYLES = {
},
};
const CB_STATUS = {
closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
"half-open": { icon: "pending", color: "#f59e0b", label: "Half-Open" },
open: { icon: "error", color: "#ef4444", label: "Open" },
};
function formatMs(ms) {
if (!ms || ms <= 0) return "—";
if (ms < 1000) return `${ms}ms`;
@@ -35,86 +42,6 @@ function formatMs(ms) {
return `${(ms / 60000).toFixed(1)}m`;
}
// ─── Circuit Breaker Card ────────────────────────────────────────────────
function CircuitBreakerCard({ breakers, onReset, loading }) {
const activeBreakers = breakers.filter((b) => b.state !== "CLOSED");
const totalBreakers = breakers.length;
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breakers</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{activeBreakers.length > 0
? `${activeBreakers.length} tripped`
: `${totalBreakers} healthy`}
</span>
{activeBreakers.length > 0 && (
<Button
size="sm"
variant="danger"
icon="restart_alt"
onClick={onReset}
disabled={loading}
>
Reset All
</Button>
)}
</div>
</div>
{breakers.length === 0 ? (
<p className="text-sm text-text-muted">
No circuit breakers active yet. They are created automatically when requests flow
through the combo pipeline.
</p>
) : (
<div className="space-y-2">
{breakers.map((b) => {
const style = STATE_STYLES[b.state] || STATE_STYLES.CLOSED;
return (
<div
key={b.name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-black/5 dark:bg-white/5"
>
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-base ${style.text}`}
aria-hidden="true"
>
{style.icon}
</span>
<span className="text-sm font-medium">{b.name.replace("combo:", "")}</span>
</div>
<div className="flex items-center gap-3">
{b.failureCount > 0 && (
<span className="text-xs text-text-muted">
{b.failureCount} failure{b.failureCount !== 1 ? "s" : ""}
</span>
)}
<span
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${style.bg} ${style.text} border ${style.border}`}
>
{style.label}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</Card>
);
}
// ─── Provider Profiles Card ──────────────────────────────────────────────
function ProviderProfilesCard({ profiles, onSave, saving }) {
const [editMode, setEditMode] = useState(false);
@@ -217,16 +144,53 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
);
}
// ─── Rate Limit Overview Card ────────────────────────────────────────────
function RateLimitOverviewCard({ rateLimitStatus, defaults }) {
// ─── Editable Rate Limit Card ────────────────────────────────────────────
function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState(defaults || {});
// Sync draft when defaults change from parent (standard prop-to-state sync)
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (defaults) setDraft(defaults);
}, [defaults]);
/* eslint-enable react-hooks/set-state-in-effect */
const handleSave = () => {
onSaveDefaults(draft);
setEditMode(false);
};
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
speed
</span>
<h2 className="text-lg font-bold">Rate Limiting</h2>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
speed
</span>
<h2 className="text-lg font-bold">Rate Limiting</h2>
</div>
{editMode ? (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditMode(false)}>
Cancel
</Button>
<Button
size="sm"
variant="primary"
icon="save"
onClick={handleSave}
disabled={saving}
>
Save
</Button>
</div>
) : (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setEditMode(true)}>
Edit
</Button>
)}
</div>
<p className="text-sm text-text-muted mb-4">
@@ -235,22 +199,34 @@ function RateLimitOverviewCard({ rateLimitStatus, defaults }) {
</p>
<div className="rounded-lg bg-black/5 dark:bg-white/5 p-4 mb-4">
<h3 className="text-xs font-bold uppercase tracking-wider mb-2 text-text-muted">
<h3 className="text-xs font-bold uppercase tracking-wider mb-3 text-text-muted">
Default Safety Net
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-lg font-bold">{defaults?.requestsPerMinute ?? "—"}</div>
<div className="text-xs text-text-muted">RPM</div>
</div>
<div>
<div className="text-lg font-bold">{formatMs(defaults?.minTimeBetweenRequests)}</div>
<div className="text-xs text-text-muted">Min Gap</div>
</div>
<div>
<div className="text-lg font-bold">{defaults?.concurrentRequests ?? "—"}</div>
<div className="text-xs text-text-muted">Max Concurrent</div>
</div>
{[
{ key: "requestsPerMinute", label: "RPM", suffix: "" },
{ key: "minTimeBetweenRequests", label: "Min Gap", suffix: "ms", format: formatMs },
{ key: "concurrentRequests", label: "Max Concurrent", suffix: "" },
].map(({ key, label, format }) => (
<div key={key}>
{editMode ? (
<input
type="number"
min="1"
value={draft[key] ?? 0}
onChange={(e) =>
setDraft((prev) => ({ ...prev, [key]: parseInt(e.target.value) || 0 }))
}
className="w-full px-2 py-1 text-lg font-bold rounded bg-white/10 border border-white/20"
/>
) : (
<div className="text-lg font-bold">
{format ? format(defaults?.[key]) : (defaults?.[key] ?? "—")}
</div>
)}
<div className="text-xs text-text-muted">{label}</div>
</div>
))}
</div>
</div>
@@ -281,6 +257,270 @@ function RateLimitOverviewCard({ rateLimitStatus, defaults }) {
);
}
// ─── Circuit Breaker Card ────────────────────────────────────────────────
function CircuitBreakerCard({ breakers, onReset, loading }) {
const activeBreakers = breakers.filter((b) => b.state !== "CLOSED");
const totalBreakers = breakers.length;
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breakers</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{activeBreakers.length > 0
? `${activeBreakers.length} tripped`
: `${totalBreakers} healthy`}
</span>
{activeBreakers.length > 0 && (
<Button
size="sm"
variant="danger"
icon="restart_alt"
onClick={onReset}
disabled={loading}
>
Reset All
</Button>
)}
</div>
</div>
{breakers.length === 0 ? (
<p className="text-sm text-text-muted">
No circuit breakers active yet. They are created automatically when requests flow
through the combo pipeline.
</p>
) : (
<div className="space-y-2">
{breakers.map((b) => {
const style = STATE_STYLES[b.state] || STATE_STYLES.CLOSED;
return (
<div
key={b.name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-black/5 dark:bg-white/5"
>
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-base ${style.text}`}
aria-hidden="true"
>
{style.icon}
</span>
<span className="text-sm font-medium">{b.name.replace("combo:", "")}</span>
</div>
<div className="flex items-center gap-3">
{b.failureCount > 0 && (
<span className="text-xs text-text-muted">
{b.failureCount} failure{b.failureCount !== 1 ? "s" : ""}
</span>
)}
<span
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${style.bg} ${style.text} border ${style.border}`}
>
{style.label}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</Card>
);
}
// ─── Policies Panel (from Security tab) ──────────────────────────────────
function PoliciesCard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const fetchPolicies = useCallback(async () => {
try {
const res = await fetch("/api/policies");
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPolicies();
const interval = setInterval(fetchPolicies, 15000);
return () => clearInterval(interval);
}, [fetchPolicies]);
const handleUnlock = async (identifier) => {
setUnlocking(identifier);
try {
const res = await fetch("/api/policies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "unlock", identifier }),
});
if (res.ok) {
notify.success(`Unlocked: ${identifier}`);
await fetchPolicies();
} else {
notify.error("Failed to unlock");
}
} catch {
notify.error("Failed to unlock");
} finally {
setUnlocking(null);
}
};
const circuitBreakers = data?.circuitBreakers || [];
const lockedIds = data?.lockedIdentifiers || [];
const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0;
if (loading) {
return (
<Card className="p-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">policy</span>
Loading policies...
</div>
</Card>
);
}
return (
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
policy
</span>
<h2 className="text-lg font-bold">Policies & Locked Identifiers</h2>
</div>
{hasIssues && (
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
<span className="material-symbols-outlined text-[16px]">refresh</span>
</Button>
)}
</div>
{!hasIssues ? (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">verified_user</span>
</div>
<div>
<p className="text-sm text-text-muted">
All systems operational no lockouts or tripped breakers
</p>
</div>
</div>
) : (
<>
{/* Circuit Breakers */}
{circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-text-muted mb-2">Circuit Breakers</p>
<div className="flex flex-col gap-1.5">
{circuitBreakers
.filter((cb) => cb.state !== "closed")
.map((cb, i) => {
const status = CB_STATUS[cb.state] || CB_STATUS.open;
return (
<div
key={cb.name || i}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
>
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[16px]"
style={{ color: status.color }}
>
{status.icon}
</span>
<span className="text-sm text-text-main font-medium">
{cb.name || cb.provider || "Unknown"}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${status.color}15`,
color: status.color,
}}
>
{status.label}
</span>
{cb.failures > 0 && (
<span className="text-xs text-text-muted">
{cb.failures} failures
</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
<div>
<p className="text-sm font-medium text-text-muted mb-2">Locked Identifiers</p>
<div className="flex flex-col gap-1.5">
{lockedIds.map((id, i) => {
const identifier = typeof id === "string" ? id : id.identifier || id.id;
return (
<div
key={identifier || i}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-red-400">
lock
</span>
<span className="font-mono text-sm text-text-main">{identifier}</span>
{typeof id === "object" && id.lockedAt && (
<span className="text-xs text-text-muted">
since {new Date(id.lockedAt).toLocaleString()}
</span>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleUnlock(identifier)}
disabled={unlocking === identifier}
className="text-xs"
>
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
</Button>
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
</Card>
);
}
// ─── Main Resilience Tab ─────────────────────────────────────────────────
export default function ResilienceTab() {
const [data, setData] = useState(null);
@@ -340,6 +580,23 @@ export default function ResilienceTab() {
}
};
const handleSaveDefaults = async (defaults) => {
try {
setSaving(true);
const res = await fetch("/api/resilience", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaults }),
});
if (!res.ok) throw new Error("Save failed");
await loadData();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading && !data) {
return (
<div className="flex items-center justify-center py-12 text-text-muted">
@@ -365,20 +622,27 @@ export default function ResilienceTab() {
return (
<div className="flex flex-col gap-6">
<CircuitBreakerCard
breakers={data?.circuitBreakers || []}
onReset={handleResetBreakers}
loading={loading}
/>
{/* 1. Provider Profiles (resilience settings by auth type) */}
<ProviderProfilesCard
profiles={data?.profiles || {}}
onSave={handleSaveProfiles}
saving={saving}
/>
<RateLimitOverviewCard
{/* 2. Rate Limiting (editable defaults + active limiters) */}
<RateLimitCard
rateLimitStatus={data?.rateLimitStatus || []}
defaults={data?.defaults || {}}
onSaveDefaults={handleSaveDefaults}
saving={saving}
/>
{/* 3. Circuit Breakers (combo pipeline) */}
<CircuitBreakerCard
breakers={data?.circuitBreakers || []}
onReset={handleResetBreakers}
loading={loading}
/>
{/* 4. Policies & Locked Identifiers (from previous Security tab) */}
<PoliciesCard />
</div>
);
}

View File

@@ -13,6 +13,19 @@ const STRATEGIES = [
},
{ value: "round-robin", label: "Round Robin", desc: "Cycle through all accounts", icon: "loop" },
{ value: "p2c", label: "P2C", desc: "Pick 2 random, use the healthier one", icon: "balance" },
{ value: "random", label: "Random", desc: "Random account each request", icon: "shuffle" },
{
value: "least-used",
label: "Least Used",
desc: "Pick least recently used account",
icon: "low_priority",
},
{
value: "cost-optimized",
label: "Cost Opt",
desc: "Prefer cheapest available account",
icon: "savings",
},
];
export default function RoutingTab() {
@@ -76,7 +89,7 @@ export default function RoutingTab() {
<h3 className="text-lg font-semibold">Routing Strategy</h3>
</div>
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="grid grid-cols-3 gap-2 mb-4" style={{ gridAutoRows: "1fr" }}>
{STRATEGIES.map((s) => (
<button
key={s.value}
@@ -132,6 +145,12 @@ export default function RoutingTab() {
"Using accounts in priority order (Fill First)."}
{settings.fallbackStrategy === "p2c" &&
"Power of Two Choices: picks 2 random accounts and routes to the healthier one."}
{settings.fallbackStrategy === "random" &&
"Randomly selects an available account for each request."}
{settings.fallbackStrategy === "least-used" &&
"Picks the account that was used least recently."}
{settings.fallbackStrategy === "cost-optimized" &&
"Prefers accounts with the lowest cost (priority-based, extensible with actual cost data)."}
</p>
</Card>

View File

@@ -3,7 +3,6 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import IPFilterSection from "./IPFilterSection";
import PoliciesPanel from "./PoliciesPanel";
export default function SecurityTab() {
const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false });
@@ -147,7 +146,6 @@ export default function SecurityTab() {
</div>
</Card>
<IPFilterSection />
<PoliciesPanel />
</div>
);
}

View File

@@ -74,6 +74,7 @@ export default function SettingsPage() {
<div className="flex flex-col gap-6">
<ThinkingBudgetTab />
<SystemPromptTab />
<CacheStatsCard />
</div>
)}
@@ -88,12 +89,7 @@ export default function SettingsPage() {
{activeTab === "resilience" && <ResilienceTab />}
{activeTab === "advanced" && (
<div className="flex flex-col gap-6">
<ProxyTab />
<CacheStatsCard />
</div>
)}
{activeTab === "advanced" && <ProxyTab />}
{activeTab === "compliance" && <ComplianceTab />}
</div>

View File

@@ -9,6 +9,7 @@ import {
getCliRuntimeStatus,
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
// Get claude settings path based on OS
const getClaudeSettingsPath = () => getCliPrimaryConfigPath("claude");
@@ -121,6 +122,13 @@ export async function POST(request: Request) {
// Write new settings
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2));
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("claude");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Settings updated successfully",
@@ -184,6 +192,13 @@ export async function DELETE() {
// Write updated settings
await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2));
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("claude");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Settings reset successfully",

View File

@@ -6,6 +6,7 @@ import path from "path";
import os from "os";
import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data");
const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json");
@@ -151,6 +152,13 @@ export async function POST(request: Request) {
await fs.writeFile(SECRETS_PATH, JSON.stringify(secrets, null, 2));
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("cline");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Cline settings applied successfully!",
@@ -210,6 +218,13 @@ export async function DELETE() {
delete secrets.openAiApiKey;
await fs.writeFile(SECRETS_PATH, JSON.stringify(secrets, null, 2));
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("cline");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "OmniRoute settings removed from Cline",

View File

@@ -9,6 +9,7 @@ import {
getCliRuntimeStatus,
} from "@/shared/services/cliRuntime";
import { createMultiBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
const getCodexConfigPath = () => getCliConfigPaths("codex").config;
const getCodexAuthPath = () => getCliConfigPaths("codex").auth;
@@ -201,6 +202,13 @@ export async function POST(request: Request) {
authData.OPENAI_API_KEY = apiKey;
await fs.writeFile(authPath, JSON.stringify(authData, null, 2));
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("codex");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Codex settings applied successfully!",
@@ -270,6 +278,13 @@ export async function DELETE() {
/* No auth file */
}
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("codex");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "OmniRoute settings removed successfully",

View File

@@ -9,6 +9,7 @@ import {
getCliRuntimeStatus,
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid");
const getDroidDir = () => path.dirname(getDroidSettingsPath());
@@ -132,6 +133,13 @@ export async function POST(request: Request) {
// Write settings
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("droid");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Factory Droid settings applied successfully!",
@@ -184,6 +192,13 @@ export async function DELETE() {
// Write updated settings
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("droid");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "OmniRoute settings removed successfully",

View File

@@ -6,6 +6,7 @@ import path from "path";
import os from "os";
import { ensureCliConfigWriteAllowed, getCliRuntimeStatus } from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
const KILO_DATA_DIR = path.join(os.homedir(), ".local", "share", "kilo");
const AUTH_PATH = path.join(KILO_DATA_DIR, "auth.json");
@@ -175,6 +176,13 @@ export async function POST(request) {
// VS Code settings not writable — not a problem for CLI
}
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("kilo");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Kilo Code settings applied successfully!",
@@ -233,6 +241,13 @@ export async function DELETE() {
/* ignore */
}
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("kilo");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "OmniRoute settings removed from Kilo Code",

View File

@@ -9,6 +9,7 @@ import {
getCliRuntimeStatus,
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw");
const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath());
@@ -132,6 +133,13 @@ export async function POST(request: Request) {
// Write settings
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("openclaw");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Open Claw settings applied successfully!",
@@ -189,6 +197,13 @@ export async function DELETE() {
// Write updated settings
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("openclaw");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "OmniRoute settings removed successfully",

View File

@@ -2,6 +2,7 @@
import { NextResponse } from "next/server";
import { getCliRuntimeStatus, CLI_TOOL_IDS } from "@/shared/services/cliRuntime";
import { getAllCliToolLastConfigured } from "@/lib/db/cliToolState";
/**
* GET /api/cli-tools/status
@@ -58,6 +59,18 @@ export async function GET() {
})
);
// Merge last-configured timestamps from SQLite
try {
const lastConfigured = getAllCliToolLastConfigured();
for (const [toolId, timestamp] of Object.entries(lastConfigured)) {
if (statuses[toolId]) {
statuses[toolId].lastConfiguredAt = timestamp;
}
}
} catch {
/* non-critical */
}
return NextResponse.json(statuses);
} catch (error) {
console.log("Error fetching CLI tool statuses:", error);

View File

@@ -9,8 +9,7 @@ export async function GET() {
// Dynamic imports for open-sse modules
const { getAllCircuitBreakerStatuses } =
await import("@/../../src/shared/utils/circuitBreaker");
const { getAllRateLimitStatus } =
await import("@omniroute/open-sse/services/rateLimitManager");
const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager");
const { PROVIDER_PROFILES, DEFAULT_API_LIMITS } =
await import("@omniroute/open-sse/config/constants");
@@ -20,7 +19,7 @@ export async function GET() {
return NextResponse.json({
profiles: settings.providerProfiles || PROVIDER_PROFILES,
defaults: DEFAULT_API_LIMITS,
defaults: { ...DEFAULT_API_LIMITS, ...(settings.rateLimitDefaults || {}) },
circuitBreakers,
rateLimitStatus,
});
@@ -34,46 +33,78 @@ export async function GET() {
}
/**
* PATCH /api/resilience — Update provider resilience profiles
* PATCH /api/resilience — Update provider resilience profiles and/or rate limit defaults
*/
export async function PATCH(request) {
try {
const body = await request.json();
const { profiles } = body;
const { profiles, defaults } = body;
if (!profiles || typeof profiles !== "object") {
return NextResponse.json({ error: "Invalid profiles payload" }, { status: 400 });
if (!profiles && !defaults) {
return NextResponse.json({ error: "Must provide profiles or defaults" }, { status: 400 });
}
// Validate profile shape
for (const [key, profile] of Object.entries(profiles)) {
if (!["oauth", "apikey"].includes(key)) {
return NextResponse.json({ error: `Invalid profile key: ${key}` }, { status: 400 });
// Validate profiles if provided
if (profiles) {
if (typeof profiles !== "object") {
return NextResponse.json({ error: "Invalid profiles payload" }, { status: 400 });
}
const required = [
"transientCooldown",
"rateLimitCooldown",
"maxBackoffLevel",
"circuitBreakerThreshold",
"circuitBreakerReset",
];
for (const field of required) {
if (typeof profile[field] !== "number" || profile[field] < 0) {
for (const [key, profile] of Object.entries(profiles)) {
if (!["oauth", "apikey"].includes(key)) {
return NextResponse.json({ error: `Invalid profile key: ${key}` }, { status: 400 });
}
const required = [
"transientCooldown",
"rateLimitCooldown",
"maxBackoffLevel",
"circuitBreakerThreshold",
"circuitBreakerReset",
];
for (const field of required) {
if (typeof profile[field] !== "number" || profile[field] < 0) {
return NextResponse.json(
{ error: `Invalid ${key}.${field}: must be a non-negative number` },
{ status: 400 }
);
}
}
}
}
// Validate defaults if provided
if (defaults) {
if (typeof defaults !== "object") {
return NextResponse.json({ error: "Invalid defaults payload" }, { status: 400 });
}
const validKeys = ["requestsPerMinute", "minTimeBetweenRequests", "concurrentRequests"];
for (const key of validKeys) {
if (
defaults[key] !== undefined &&
(typeof defaults[key] !== "number" || defaults[key] < 1)
) {
return NextResponse.json(
{ error: `Invalid ${key}.${field}: must be a non-negative number` },
{ error: `Invalid defaults.${key}: must be a positive number` },
{ status: 400 }
);
}
}
}
await updateSettings({ providerProfiles: profiles });
const updates: Record<string, any> = {};
if (profiles) updates.providerProfiles = profiles;
if (defaults) updates.rateLimitDefaults = defaults;
return NextResponse.json({ ok: true, profiles });
await updateSettings(updates);
return NextResponse.json({
ok: true,
...(profiles ? { profiles } : {}),
...(defaults ? { defaults } : {}),
});
} catch (err) {
console.error("[API] PATCH /api/resilience error:", err);
return NextResponse.json(
{ error: err.message || "Failed to save resilience profiles" },
{ error: err.message || "Failed to save resilience settings" },
{ status: 500 }
);
}

123
src/lib/db/cliToolState.ts Normal file
View File

@@ -0,0 +1,123 @@
/**
* CLI Tool State Persistence
*
* Stores last-configured timestamps and initial config snapshots
* for CLI tools in the key_value table.
*
* Namespaces:
* - cliToolLastConfig: ISO timestamp of last configuration
* - cliToolInitialConfig: JSON snapshot of pre-OmniRoute configuration
*
* @module lib/db/cliToolState
*/
import { getDbInstance, isBuildPhase, isCloud } from "./core";
// ──────────────── Last Configured Timestamp ────────────────
/**
* Save last-configured timestamp for a CLI tool.
*/
export function saveCliToolLastConfigured(
toolId: string,
timestamp: string = new Date().toISOString()
) {
if (isBuildPhase || isCloud) return;
const db = getDbInstance();
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
"cliToolLastConfig",
toolId,
JSON.stringify(timestamp)
);
}
/**
* Get last-configured timestamp for a CLI tool.
* @returns ISO timestamp string or null if never configured.
*/
export function getCliToolLastConfigured(toolId: string): string | null {
if (isBuildPhase || isCloud) return null;
const db = getDbInstance();
const row: any = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get("cliToolLastConfig", toolId);
return row ? JSON.parse(row.value) : null;
}
/**
* Get all CLI tool last-configured timestamps.
* @returns Record<toolId, ISO timestamp>
*/
export function getAllCliToolLastConfigured(): Record<string, string> {
if (isBuildPhase || isCloud) return {};
const db = getDbInstance();
const rows: any[] = db
.prepare("SELECT key, value FROM key_value WHERE namespace = ?")
.all("cliToolLastConfig");
const result: Record<string, string> = {};
for (const row of rows) {
result[row.key] = JSON.parse(row.value);
}
return result;
}
/**
* Delete last-configured timestamp for a CLI tool.
*/
export function deleteCliToolLastConfigured(toolId: string) {
if (isBuildPhase || isCloud) return;
const db = getDbInstance();
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(
"cliToolLastConfig",
toolId
);
}
// ──────────────── Initial Config Snapshot ────────────────
/**
* Save the initial (pre-OmniRoute) config snapshot for a CLI tool.
* Only saves if no snapshot exists yet (first-time only).
* @returns true if saved, false if snapshot already exists.
*/
export function saveCliToolInitialConfig(toolId: string, config: Record<string, any>): boolean {
if (isBuildPhase || isCloud) return false;
const db = getDbInstance();
// Only save if not already stored
const existing: any = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get("cliToolInitialConfig", toolId);
if (existing) return false;
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
"cliToolInitialConfig",
toolId,
JSON.stringify(config)
);
return true;
}
/**
* Get the initial config snapshot for a CLI tool.
* @returns Config object or null if no snapshot exists.
*/
export function getCliToolInitialConfig(toolId: string): Record<string, any> | null {
if (isBuildPhase || isCloud) return null;
const db = getDbInstance();
const row: any = db
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
.get("cliToolInitialConfig", toolId);
return row ? JSON.parse(row.value) : null;
}
/**
* Delete the initial config snapshot for a CLI tool.
*/
export function deleteCliToolInitialConfig(toolId: string) {
if (isBuildPhase || isCloud) return;
const db = getDbInstance();
db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run(
"cliToolInitialConfig",
toolId
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import Card from "./Card";
import { CardSkeleton } from "./Loading";
import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting";
@@ -15,6 +15,8 @@ import {
WeeklySquares7d,
ModelTable,
ProviderCostDonut,
ModelOverTimeChart,
ProviderTable,
} from "./analytics";
// ============================================================================
@@ -55,11 +57,37 @@ export default function UsageAnalytics() {
{ value: "all", label: "All" },
];
const topModel = useMemo(() => {
const models = analytics?.byModel || [];
return models.length > 0 ? models[0].model : "—";
}, [analytics]);
const topProvider = useMemo(() => {
const providers = analytics?.byProvider || [];
return providers.length > 0 ? providers[0].provider : "—";
}, [analytics]);
const busiestDay = useMemo(() => {
const wp = analytics?.weeklyPattern || [];
if (!wp.length) return "—";
const max = wp.reduce((a, b) => (a.avgTokens > b.avgTokens ? a : b), wp[0]);
return max.avgTokens > 0 ? max.day : "—";
}, [analytics]);
const providerCount = useMemo(() => {
return (analytics?.byProvider || []).length;
}, [analytics]);
if (loading && !analytics) return <CardSkeleton />;
if (error) return <Card className="p-6 text-center text-red-500">Error: {error}</Card>;
const s = analytics?.summary || {};
// ── Derived insight values ──
const avgTokensPerReq = s.totalRequests > 0 ? Math.round(s.totalTokens / s.totalRequests) : 0;
const costPerReq = s.totalRequests > 0 ? s.totalCost / s.totalRequests : 0;
const ioRatio = s.completionTokens > 0 ? (s.promptTokens / s.completionTokens).toFixed(1) : "—";
return (
<div className="flex flex-col gap-5">
{/* Header + Time Range */}
@@ -85,7 +113,7 @@ export default function UsageAnalytics() {
</div>
</div>
{/* Summary Cards — 7 columns: tokens, input, output, cost, accounts, keys, models */}
{/* Summary Cards — Row 1: Core metrics */}
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
<StatCard
icon="generating_tokens"
@@ -116,6 +144,32 @@ export default function UsageAnalytics() {
<StatCard icon="model_training" label="Models" value={s.uniqueModels || 0} />
</div>
{/* Summary Cards — Row 2: Derived insights */}
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
<StatCard
icon="speed"
label="Avg Tokens/Req"
value={fmt(avgTokensPerReq)}
color="text-cyan-500"
/>
<StatCard
icon="request_quote"
label="Cost/Request"
value={fmtCost(costPerReq)}
color="text-orange-500"
/>
<StatCard
icon="compare_arrows"
label="I/O Ratio"
value={`${ioRatio}x`}
color="text-violet-500"
/>
<StatCard icon="star" label="Top Model" value={topModel} color="text-pink-500" />
<StatCard icon="cloud" label="Top Provider" value={topProvider} color="text-teal-500" />
<StatCard icon="today" label="Busiest Day" value={busiestDay} color="text-rose-500" />
<StatCard icon="dns" label="Providers" value={providerCount} color="text-indigo-500" />
</div>
{/* Activity Heatmap + Weekly Widgets */}
<div
style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16, alignItems: "stretch" }}
@@ -133,37 +187,26 @@ export default function UsageAnalytics() {
<ProviderCostDonut byProvider={analytics?.byProvider} />
</div>
{/* Model Usage Over Time (stacked area) */}
<ModelOverTimeChart
dailyByModel={analytics?.dailyByModel}
modelNames={analytics?.modelNames}
/>
{/* Account Donut + API Key Donut */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AccountDonut byAccount={analytics?.byAccount} />
<ApiKeyDonut byApiKey={analytics?.byApiKey} />
</div>
{/* Provider Breakdown Table */}
<ProviderTable byProvider={analytics?.byProvider} />
{/* API Key Table */}
<ApiKeyTable byApiKey={analytics?.byApiKey} />
{/* Model Breakdown Table */}
<ModelTable byModel={analytics?.byModel} summary={s} />
{/* Bottom Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Requests</span>
<div className="text-lg font-bold mt-1">{fmtFull(s.totalRequests)}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Streak</span>
<div className="text-lg font-bold mt-1">{s.streak || 0}d</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Total Tokens</span>
<div className="text-lg font-bold mt-1">{fmt(s.totalTokens)}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Usage Cost</span>
<div className="text-lg font-bold text-amber-500 mt-1">{fmtCost(s.totalCost)}</div>
</Card>
</div>
</div>
);
}

View File

@@ -21,11 +21,23 @@ import {
Cell,
PieChart,
Pie,
AreaChart,
Area,
} from "recharts";
// ── Custom Tooltip for dark theme ──────────────────────────────────────────
function DarkTooltip({ active, payload, label, formatter }: { active?: boolean; payload?: any[]; label?: any; formatter?: Function }) {
function DarkTooltip({
active,
payload,
label,
formatter,
}: {
active?: boolean;
payload?: any[];
label?: any;
formatter?: Function;
}) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border border-white/10 bg-surface px-3 py-2 text-xs shadow-lg">
@@ -65,7 +77,19 @@ export function SortIndicator({ active, sortOrder }: { active: boolean; sortOrde
// ── StatCard ───────────────────────────────────────────────────────────────
export function StatCard({ icon, label, value, subValue, color = "text-text-main" }: { icon: any; label: any; value: any; subValue?: any; color?: string }) {
export function StatCard({
icon,
label,
value,
subValue,
color = "text-text-main",
}: {
icon: any;
label: any;
value: any;
subValue?: any;
color?: string;
}) {
return (
<Card className="px-4 py-3 flex flex-col gap-1">
<div className="flex items-center gap-2 text-text-muted text-xs uppercase font-semibold tracking-wider">
@@ -161,7 +185,8 @@ export function ActivityHeatmap({ activityMap }) {
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">Activity</h3>
<span className="text-xs text-text-muted">
{Object.keys(activityMap || {}).length} active days ·{" "}
{fmt(Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0))} tokens · 365 days
{fmt(Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0))} tokens
· 365 days
</span>
</div>
@@ -320,7 +345,15 @@ export function DailyTrendChart({ dailyTrend }) {
// ── Cost-aware Tooltip ─────────────────────────────────────────────────────
function CostTooltip({ active, payload, label }: { active?: boolean; payload?: any[]; label?: any }) {
function CostTooltip({
active,
payload,
label,
}: {
active?: boolean;
payload?: any[];
label?: any;
}) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border border-white/10 bg-surface px-3 py-2 text-xs shadow-lg">
@@ -1074,3 +1107,238 @@ export function ProviderCostDonut({ byProvider }) {
</Card>
);
}
// ── ModelOverTimeChart (Stacked Area) ──────────────────────────────────────
export function ModelOverTimeChart({ dailyByModel, modelNames }) {
const data = useMemo(() => dailyByModel || [], [dailyByModel]);
const models = useMemo(() => modelNames || [], [modelNames]);
// Prepare chart data — format dates (must be before early return for rules-of-hooks)
const chartData = useMemo(() => {
return data.map((d) => {
const row = { ...d };
// Short date label
if (d.date) {
const parts = d.date.split("-");
row.dateLabel = `${parts[1]}/${parts[2]}`;
}
return row;
});
}, [data]);
if (!data.length || !models.length) {
return (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Model Usage Over Time
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
</Card>
);
}
return (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Model Usage Over Time
</h3>
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
<XAxis
dataKey="dateLabel"
tick={{ fontSize: 10, fill: "var(--text-muted)" }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: "var(--text-muted)" }}
tickFormatter={(v) => fmt(v)}
axisLine={false}
tickLine={false}
width={50}
/>
<Tooltip content={<DarkTooltip formatter={fmt} />} />
{models.map((m, i) => (
<Area
key={m}
type="monotone"
dataKey={m}
stackId="1"
stroke={getModelColor(i)}
fill={getModelColor(i)}
fillOpacity={0.4}
strokeWidth={1.5}
animationDuration={600}
/>
))}
</AreaChart>
</ResponsiveContainer>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2 text-[10px] text-text-muted">
{models.map((m, i) => (
<span key={m} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(i) }}
/>
{m}
</span>
))}
</div>
</Card>
);
}
// ── ProviderTable ──────────────────────────────────────────────────────────
export function ProviderTable({ byProvider }) {
const [sortBy, setSortBy] = useState("totalTokens");
const [sortOrder, setSortOrder] = useState("desc");
const data = useMemo(() => byProvider || [], [byProvider]);
const totalTokens = useMemo(() => data.reduce((acc, p) => acc + p.totalTokens, 0), [data]);
const toggleSort = useCallback(
(field) => {
if (sortBy === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(field);
setSortOrder("desc");
}
},
[sortBy]
);
const sorted = useMemo(() => {
const arr = [...data];
arr.sort((a, b) => {
const va = a[sortBy] ?? 0;
const vb = b[sortBy] ?? 0;
if (typeof va === "string")
return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
return sortOrder === "asc" ? va - vb : vb - va;
});
return arr;
}, [data, sortBy, sortOrder]);
if (!data.length) {
return (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Provider Breakdown
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
</Card>
);
}
return (
<Card className="overflow-hidden">
<div className="p-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
Provider Breakdown
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]">
<tr>
<th
className="px-4 py-2.5 text-left cursor-pointer group"
onClick={() => toggleSort("provider")}
>
Provider <SortIndicator active={sortBy === "provider"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("requests")}
>
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("promptTokens")}
>
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("completionTokens")}
>
Output{" "}
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("totalTokens")}
>
Total <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("cost")}
>
Cost <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
</th>
<th className="px-4 py-2.5 text-right w-36">Share</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sorted.map((p, i) => {
const pct = totalTokens > 0 ? ((p.totalTokens / totalTokens) * 100).toFixed(1) : "0";
return (
<tr
key={p.provider}
className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: PROVIDER_COLORS[i % PROVIDER_COLORS.length] }}
/>
<span className="font-medium capitalize">{p.provider}</span>
</div>
</td>
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
{fmtFull(p.requests)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-primary">
{fmt(p.promptTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-emerald-500">
{fmt(p.completionTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono font-semibold">
{fmt(p.totalTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-amber-500">
{fmtCost(p.cost)}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center gap-2 justify-end">
<div className="w-16 h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${pct}%`,
backgroundColor: PROVIDER_COLORS[i % PROVIDER_COLORS.length],
}}
/>
</div>
<span className="text-xs font-mono text-text-muted w-10 text-right">
{pct}%
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -22,4 +22,6 @@ export {
ModelTable,
UsageDetail,
ProviderCostDonut,
ModelOverTimeChart,
ProviderTable,
} from "./charts";

View File

@@ -29,7 +29,10 @@ const markMutexes = new Map<string, Promise<void>>();
* @param {string} provider - Provider name
* @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account)
*/
export async function getProviderCredentials(provider: string, excludeConnectionId: string | null = null) {
export async function getProviderCredentials(
provider: string,
excludeConnectionId: string | null = null
) {
// Acquire mutex to prevent race conditions
const currentMutex = selectionMutex;
let resolveMutex: (() => void) | undefined;
@@ -105,7 +108,8 @@ export async function getProviderCredentials(provider: string, excludeConnection
(c) => c.rateLimitedUntil && new Date(c.rateLimitedUntil).getTime() > Date.now()
);
const earliestConn = rateLimitedConns.sort(
(a: any, b: any) => new Date(a.rateLimitedUntil).getTime() - new Date(b.rateLimitedUntil).getTime()
(a: any, b: any) =>
new Date(a.rateLimitedUntil).getTime() - new Date(b.rateLimitedUntil).getTime()
)[0];
log.warn(
"AUTH",
@@ -166,6 +170,41 @@ export async function getProviderCredentials(provider: string, excludeConnection
consecutiveUseCount: 1,
});
}
} else if (strategy === "p2c") {
// Power of Two Choices: pick 2 random, choose the one with fewer failures
if (availableConnections.length <= 2) {
connection = availableConnections[0];
} else {
const i = Math.floor(Math.random() * availableConnections.length);
let j = Math.floor(Math.random() * (availableConnections.length - 1));
if (j >= i) j++;
const a = availableConnections[i];
const b = availableConnections[j];
// Prefer the one with fewer consecutive uses / better health
const scoreA = (a.consecutiveUseCount || 0) + (a.lastError ? 10 : 0);
const scoreB = (b.consecutiveUseCount || 0) + (b.lastError ? 10 : 0);
connection = scoreA <= scoreB ? a : b;
}
} else if (strategy === "random") {
// Random: Fisher-Yates-inspired random pick
const idx = Math.floor(Math.random() * availableConnections.length);
connection = availableConnections[idx];
} else if (strategy === "least-used") {
// Least Used: pick the one with oldest lastUsedAt
const sorted = [...availableConnections].sort((a: any, b: any) => {
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
if (!a.lastUsedAt) return -1;
if (!b.lastUsedAt) return 1;
return new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime();
});
connection = sorted[0];
} else if (strategy === "cost-optimized") {
// Cost Optimized: sort by priority ascending (lower = cheaper/preferred)
// Future: can be enhanced with actual cost data per provider
const sorted = [...availableConnections].sort(
(a: any, b: any) => (a.priority || 999) - (b.priority || 999)
);
connection = sorted[0];
} else {
// Default: fill-first (already sorted by priority in getProviderConnections)
connection = availableConnections[0];

View File

@@ -4,7 +4,13 @@
export interface Settings {
requireLogin: boolean;
hasPassword: boolean;
fallbackStrategy: "fill-first" | "round-robin";
fallbackStrategy:
| "fill-first"
| "round-robin"
| "p2c"
| "random"
| "least-used"
| "cost-optimized";
stickyRoundRobinLimit: number;
jwtSecret?: string;
}