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
This commit is contained in:
diegosouzapw
2026-02-23 17:01:32 -03:00
parent 19eeebae95
commit ce6d7dc6bf
3 changed files with 200 additions and 48 deletions

View File

@@ -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

View File

@@ -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": {

View File

@@ -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<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [usageStats, setUsageStats] = useState<Record<string, KeyUsageStats>>({});
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<string, KeyUsageStats> = {};
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() {
</div>
)}
{/* Stats Summary Cards */}
{keys.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-primary/10">
<span className="material-symbols-outlined text-primary text-lg">vpn_key</span>
</div>
<div>
<p className="text-2xl font-bold">{keys.length}</p>
<p className="text-xs text-text-muted">Total Keys</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-500/10">
<span className="material-symbols-outlined text-amber-500 text-lg">lock</span>
</div>
<div>
<p className="text-2xl font-bold">
{
keys.filter((k) => Array.isArray(k.allowedModels) && k.allowedModels.length > 0)
.length
}
</p>
<p className="text-xs text-text-muted">Restricted</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-blue-500/10">
<span className="material-symbols-outlined text-blue-500 text-lg">bar_chart</span>
</div>
<div>
<p className="text-2xl font-bold">
{Object.values(usageStats).reduce((sum, s) => sum + s.totalRequests, 0)}
</p>
<p className="text-xs text-text-muted">Total Requests</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-emerald-500/10">
<span className="material-symbols-outlined text-emerald-500 text-lg">
model_training
</span>
</div>
<div>
<p className="text-2xl font-bold">{allModels.length}</p>
<p className="text-xs text-text-muted">Models Available</p>
</div>
</div>
</Card>
</div>
)}
{/* Header Card */}
<Card>
<div className="flex items-center justify-between mb-4">
@@ -338,67 +434,100 @@ export default function ApiManagerPageClient() {
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-surface/50 border-b border-border text-xs font-semibold text-text-muted uppercase tracking-wider">
<div className="col-span-3">Name</div>
<div className="col-span-4">Key</div>
<div className="col-span-2">Name</div>
<div className="col-span-3">Key</div>
<div className="col-span-2">Permissions</div>
<div className="col-span-2">Usage</div>
<div className="col-span-1">Created</div>
<div className="col-span-2 text-right">Actions</div>
</div>
{/* Table Rows */}
{keys.map((key) => (
<div
key={key.id}
className="grid grid-cols-12 gap-4 px-4 py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 hover:bg-surface/30 transition-colors group"
>
<div className="col-span-3 flex items-center gap-2">
<span className="material-symbols-outlined text-text-muted text-sm">label</span>
<span className="text-sm font-medium truncate">{key.name}</span>
</div>
<div className="col-span-4 flex items-center">
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
</div>
<div className="col-span-2 flex items-center">
{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 (
<div
key={key.id}
className="grid grid-cols-12 gap-4 px-4 py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 hover:bg-surface/30 transition-colors group"
>
<div className="col-span-2 flex items-center gap-2">
<span
className={`material-symbols-outlined text-sm ${isRestricted ? "text-amber-500" : "text-emerald-500"}`}
>
{isRestricted ? "lock" : "lock_open"}
</span>
<span className="text-sm font-medium truncate" title={key.name}>
{key.name}
</span>
</div>
<div className="col-span-3 flex items-center gap-1.5">
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
<button
onClick={() => copy(key.key, key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all shrink-0"
title="Copy masked key"
>
<span className="material-symbols-outlined text-[14px]">
{copied === key.id ? "check" : "content_copy"}
</span>
</button>
</div>
<div className="col-span-2 flex items-center">
{isRestricted ? (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock</span>
{key.allowedModels.length}{" "}
{key.allowedModels.length === 1 ? "model" : "models"}
</button>
) : (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock_open</span>
All models
</button>
)}
</div>
<div className="col-span-2 flex flex-col justify-center">
<span className="text-sm font-medium tabular-nums">
{stats?.totalRequests ?? 0}{" "}
<span className="text-text-muted font-normal text-xs">reqs</span>
</span>
{stats?.lastUsed ? (
<span className="text-[10px] text-text-muted">
Last: {new Date(stats.lastUsed).toLocaleDateString()}
</span>
) : (
<span className="text-[10px] text-text-muted italic">Never used</span>
)}
</div>
<div className="col-span-1 flex items-center text-sm text-text-muted">
{new Date(key.createdAt).toLocaleDateString()}
</div>
<div className="col-span-2 flex items-center justify-end gap-1">
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors"
className="p-2 hover:bg-primary/10 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
title="Edit permissions"
>
<span className="material-symbols-outlined text-[14px]">lock</span>
{key.allowedModels.length}{" "}
{key.allowedModels.length === 1 ? "model" : "models"}
<span className="material-symbols-outlined text-[18px]">tune</span>
</button>
) : (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors"
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
title="Delete key"
>
<span className="material-symbols-outlined text-[14px]">lock_open</span>
All models
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
)}
</div>
</div>
<div className="col-span-1 flex items-center text-sm text-text-muted">
{new Date(key.createdAt).toLocaleDateString()}
</div>
<div className="col-span-2 flex items-center justify-end gap-1">
<button
onClick={() => handleOpenPermissions(key)}
className="p-2 hover:bg-primary/10 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
title="Edit permissions"
>
<span className="material-symbols-outlined text-[18px]">tune</span>
</button>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
title="Delete key"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</div>
))}
);
})}
</div>
)}
</Card>