From ce6d7dc6bffebca51c191e9a926ed20addb6df59 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 23 Feb 2026 17:01:32 -0300 Subject: [PATCH] feat(api-manager): enhance with usage stats, status badges, and stats dashboard (#118) - Add stats summary cards (total keys, restricted, total requests, models available) - Add per-key usage statistics (total requests, last used timestamp) - Add copy button on each key row - Add color-coded lock/unlock status icons per key - Bump version to 1.4.0 --- CHANGELOG.md | 23 ++ package.json | 2 +- .../api-manager/ApiManagerPageClient.tsx | 223 ++++++++++++++---- 3 files changed, 200 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70157efb39..3cbfd1e510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.4.0] — 2026-02-23 + +> ### ✨ Feature Release — Dedicated API Key Manager with Model Permissions +> +> Community-contributed API Key Manager page with model-level access control, enhanced with usage statistics, key status indicators, and improved UX. + +### ✨ New Features + +- **Dedicated API Key Manager** — New `/dashboard/api-manager` page for managing API keys, extracted from the Endpoint page. Includes create, delete, and permissions management with a clean table UI ([PR #118](https://github.com/diegosouzapw/OmniRoute/pull/118) by [@nyatoru](https://github.com/nyatoru)) +- **Model-Level API Key Permissions** — Restrict API keys to specific models using `allowed_models` with wildcard pattern support (e.g., `openai/*`). Toggle between "Allow All" and "Restrict" modes with an intuitive provider-grouped model selector +- **API Key Validation Cache** — 3-tier caching layer (validation, metadata, permission) reduces database hits on every request, with automatic cache invalidation on key changes +- **Usage Statistics Per Key** — Each API key shows total request count and last used timestamp, with a stats summary dashboard (total keys, restricted keys, total requests, models available) +- **Key Status Indicators** — Color-coded lock/unlock icons and copy buttons on each key row for quick identification of restricted vs unrestricted keys + +### 🔧 Improvements + +- **Endpoint Page Simplified** — API key management removed from Endpoint page and replaced with a prominent link to the API Manager +- **Sidebar Navigation** — New "API Manager" entry with `vpn_key` icon in the sidebar +- **Prepared Statements** — API key database operations now use cached prepared statements for better performance +- **Input Validation** — XSS-safe sanitization and regex validation for key names; ID format validation for API calls + +--- + ## [1.3.1] — 2026-02-23 > ### 🐛 Bugfix Release — Proxy Connection Tests & Compatible Provider Display diff --git a/package.json b/package.json index 17577333ef..025095413e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "1.3.1", + "version": "1.4.0", "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": { diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 79bb756a2c..42f1e84c25 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -56,6 +56,11 @@ interface ApiKey { createdAt: string; } +interface KeyUsageStats { + totalRequests: number; + lastUsed: string | null; +} + interface Model { id: string; owned_by: string; @@ -76,6 +81,7 @@ export default function ApiManagerPageClient() { const [searchModel, setSearchModel] = useState(""); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [usageStats, setUsageStats] = useState>({}); const { copied, copy } = useCopyToClipboard(); @@ -102,6 +108,8 @@ export default function ApiManagerPageClient() { if (res.ok) { const data = await res.json(); setKeys(data.keys || []); + // Fetch usage stats after keys are loaded + fetchUsageStats(data.keys || []); } } catch (error) { console.log("Error fetching keys:", error); @@ -110,6 +118,35 @@ export default function ApiManagerPageClient() { } }; + const fetchUsageStats = async (apiKeys: ApiKey[]) => { + if (apiKeys.length === 0) return; + try { + const res = await fetch("/api/usage/call-logs?limit=1000"); + if (!res.ok) return; + const logs = await res.json(); + const stats: Record = {}; + + for (const key of apiKeys) { + const keyLogs = (logs || []).filter( + (log: any) => log.apiKeyId === key.id || log.apiKeyName === key.name + ); + stats[key.id] = { + totalRequests: keyLogs.length, + lastUsed: + keyLogs.length > 0 + ? keyLogs.sort( + (a: any, b: any) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + )[0]?.timestamp + : null, + }; + } + setUsageStats(stats); + } catch (e) { + console.log("Error fetching usage stats:", e); + } + }; + const clearError = useCallback(() => setError(null), []); const handleCreateKey = async () => { @@ -285,6 +322,65 @@ export default function ApiManagerPageClient() { )} + {/* Stats Summary Cards */} + {keys.length > 0 && ( +
+ +
+
+ vpn_key +
+
+

{keys.length}

+

Total Keys

+
+
+
+ +
+
+ lock +
+
+

+ { + keys.filter((k) => Array.isArray(k.allowedModels) && k.allowedModels.length > 0) + .length + } +

+

Restricted

+
+
+
+ +
+
+ bar_chart +
+
+

+ {Object.values(usageStats).reduce((sum, s) => sum + s.totalRequests, 0)} +

+

Total Requests

+
+
+
+ +
+
+ + model_training + +
+
+

{allModels.length}

+

Models Available

+
+
+
+
+ )} + {/* Header Card */}
@@ -338,67 +434,100 @@ export default function ApiManagerPageClient() {
{/* Table Header */}
-
Name
-
Key
+
Name
+
Key
Permissions
+
Usage
Created
Actions
{/* Table Rows */} - {keys.map((key) => ( -
-
- label - {key.name} -
-
- {key.key} -
-
- {Array.isArray(key.allowedModels) && key.allowedModels.length > 0 ? ( + {keys.map((key) => { + const stats = usageStats[key.id]; + const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0; + return ( +
+
+ + {isRestricted ? "lock" : "lock_open"} + + + {key.name} + +
+
+ {key.key} + +
+
+ {isRestricted ? ( + + ) : ( + + )} +
+
+ + {stats?.totalRequests ?? 0}{" "} + reqs + + {stats?.lastUsed ? ( + + Last: {new Date(stats.lastUsed).toLocaleDateString()} + + ) : ( + Never used + )} +
+
+ {new Date(key.createdAt).toLocaleDateString()} +
+
- ) : ( - )} +
-
- {new Date(key.createdAt).toLocaleDateString()} -
-
- - -
-
- ))} + ); + })}
)}