Repository navigation
feat(dashboard): add search and filters to /dashboard/api-manager #2628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,6 +5,16 @@ import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; | |||||||||||||||||||||||||||||||||||||||||
| import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; | ||||||||||||||||||||||||||||||||||||||||||
| import { useTranslations } from "next-intl"; | ||||||||||||||||||||||||||||||||||||||||||
| import { getProviderDisplayName } from "@/lib/display/names"; | ||||||||||||||||||||||||||||||||||||||||||
| import ApiKeyFilterBar from "./components/ApiKeyFilterBar"; | ||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||
| isKeyActive, | ||||||||||||||||||||||||||||||||||||||||||
| isExpired, | ||||||||||||||||||||||||||||||||||||||||||
| isRestricted as isKeyRestricted, | ||||||||||||||||||||||||||||||||||||||||||
| classifyKeyStatus, | ||||||||||||||||||||||||||||||||||||||||||
| computeApiKeyCounts, | ||||||||||||||||||||||||||||||||||||||||||
| } from "./apiManagerPageUtils"; | ||||||||||||||||||||||||||||||||||||||||||
| import type { KeyStatus, KeyType } from "./apiManagerPageUtils"; | ||||||||||||||||||||||||||||||||||||||||||
| import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManagerPageStorage"; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Constants for validation | ||||||||||||||||||||||||||||||||||||||||||
| const MAX_KEY_NAME_LENGTH = 200; | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -121,6 +131,11 @@ export default function ApiManagerPageClient() { | |||||||||||||||||||||||||||||||||||||||||
| const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({}); | ||||||||||||||||||||||||||||||||||||||||||
| const [allowKeyReveal, setAllowKeyReveal] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const [searchQuery, setSearchQuery] = useState(""); | ||||||||||||||||||||||||||||||||||||||||||
| const [activeOnly, setActiveOnly] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||
| const [statusFilter, setStatusFilter] = useState<KeyStatus | null>(null); | ||||||||||||||||||||||||||||||||||||||||||
| const [typeFilter, setTypeFilter] = useState<KeyType | null>(null); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const { copied, copy } = useCopyToClipboard(); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -129,6 +144,14 @@ export default function ApiManagerPageClient() { | |||||||||||||||||||||||||||||||||||||||||
| fetchConnections(); | ||||||||||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||
| setActiveOnly(readActiveOnlyPreference()); | ||||||||||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||
| writeActiveOnlyPreference(activeOnly); | ||||||||||||||||||||||||||||||||||||||||||
| }, [activeOnly]); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const fetchModels = async () => { | ||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||
| const res = await fetch("/v1/models"); | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -239,6 +262,49 @@ export default function ApiManagerPageClient() { | |||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const clearPageError = useCallback(() => setPageError(null), []); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const keyCounts = useMemo(() => computeApiKeyCounts(keys), [keys]); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const filteredKeys = useMemo(() => { | ||||||||||||||||||||||||||||||||||||||||||
| let list = keys; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // 1. activeOnly toggle (shortcut for the most common case) | ||||||||||||||||||||||||||||||||||||||||||
| if (activeOnly) { | ||||||||||||||||||||||||||||||||||||||||||
| list = list.filter(isKeyActive); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // 2. status chip filter | ||||||||||||||||||||||||||||||||||||||||||
| if (statusFilter === "active") list = list.filter(isKeyActive); | ||||||||||||||||||||||||||||||||||||||||||
| else if (statusFilter === "disabled") list = list.filter((k) => k.isActive === false); | ||||||||||||||||||||||||||||||||||||||||||
| else if (statusFilter === "banned") list = list.filter((k) => k.isBanned === true); | ||||||||||||||||||||||||||||||||||||||||||
| else if (statusFilter === "expired") list = list.filter(isExpired); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // 3. type chip filter | ||||||||||||||||||||||||||||||||||||||||||
| if (typeFilter === "manage") list = list.filter((k) => k.scopes?.includes("manage")); | ||||||||||||||||||||||||||||||||||||||||||
| else if (typeFilter === "restricted") list = list.filter(isKeyRestricted); | ||||||||||||||||||||||||||||||||||||||||||
| else if (typeFilter === "standard") | ||||||||||||||||||||||||||||||||||||||||||
| list = list.filter((k) => !k.scopes?.includes("manage") && !isKeyRestricted(k)); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+275
to
+285
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The filtering logic for For example, if a key is both To ensure consistency, use the classification helpers directly in the filter logic.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // 4. search query (case-insensitive substring on name and key) | ||||||||||||||||||||||||||||||||||||||||||
| if (searchQuery.trim()) { | ||||||||||||||||||||||||||||||||||||||||||
| const q = searchQuery.toLowerCase(); | ||||||||||||||||||||||||||||||||||||||||||
| list = list.filter( | ||||||||||||||||||||||||||||||||||||||||||
| (k) => k.name.toLowerCase().includes(q) || k.key.toLowerCase().includes(q) | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+288
to
+293
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return list; | ||||||||||||||||||||||||||||||||||||||||||
| }, [keys, activeOnly, statusFilter, typeFilter, searchQuery]); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const isFiltered = | ||||||||||||||||||||||||||||||||||||||||||
| activeOnly || statusFilter !== null || typeFilter !== null || searchQuery.trim() !== ""; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const handleClearFilters = () => { | ||||||||||||||||||||||||||||||||||||||||||
| setSearchQuery(""); | ||||||||||||||||||||||||||||||||||||||||||
| setActiveOnly(false); | ||||||||||||||||||||||||||||||||||||||||||
| setStatusFilter(null); | ||||||||||||||||||||||||||||||||||||||||||
| setTypeFilter(null); | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const handleCreateKey = async () => { | ||||||||||||||||||||||||||||||||||||||||||
| // Validate raw input first, then sanitize | ||||||||||||||||||||||||||||||||||||||||||
| const validation = validateKeyName(newKeyName, t); | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -555,6 +621,21 @@ export default function ApiManagerPageClient() { | |||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| {/* Filter Bar — shown when there are keys */} | ||||||||||||||||||||||||||||||||||||||||||
| {keys.length > 0 && ( | ||||||||||||||||||||||||||||||||||||||||||
| <ApiKeyFilterBar | ||||||||||||||||||||||||||||||||||||||||||
| counts={keyCounts} | ||||||||||||||||||||||||||||||||||||||||||
| searchQuery={searchQuery} | ||||||||||||||||||||||||||||||||||||||||||
| onSearchChange={setSearchQuery} | ||||||||||||||||||||||||||||||||||||||||||
| activeOnly={activeOnly} | ||||||||||||||||||||||||||||||||||||||||||
| onActiveOnlyChange={setActiveOnly} | ||||||||||||||||||||||||||||||||||||||||||
| statusFilter={statusFilter} | ||||||||||||||||||||||||||||||||||||||||||
| onStatusChange={setStatusFilter} | ||||||||||||||||||||||||||||||||||||||||||
| typeFilter={typeFilter} | ||||||||||||||||||||||||||||||||||||||||||
| onTypeChange={setTypeFilter} | ||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| {/* Keys List Card */} | ||||||||||||||||||||||||||||||||||||||||||
| <Card> | ||||||||||||||||||||||||||||||||||||||||||
| <div className="flex items-center justify-between mb-4"> | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -563,7 +644,19 @@ export default function ApiManagerPageClient() { | |||||||||||||||||||||||||||||||||||||||||
| <span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span> | ||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||||||||||||||
| <h3 className="font-semibold">{t("registeredKeys")}</h3> | ||||||||||||||||||||||||||||||||||||||||||
| <h3 className="font-semibold"> | ||||||||||||||||||||||||||||||||||||||||||
| {t("registeredKeys")} | ||||||||||||||||||||||||||||||||||||||||||
| {isFiltered && ( | ||||||||||||||||||||||||||||||||||||||||||
| <span className="ml-1.5 text-sm font-normal text-text-muted"> | ||||||||||||||||||||||||||||||||||||||||||
| ({t("shownOf", { shown: filteredKeys.length, total: keys.length })}) | ||||||||||||||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||
| {!isFiltered && ( | ||||||||||||||||||||||||||||||||||||||||||
| <span className="ml-1.5 text-sm font-normal text-text-muted"> | ||||||||||||||||||||||||||||||||||||||||||
| ({keys.length}) | ||||||||||||||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||
| </h3> | ||||||||||||||||||||||||||||||||||||||||||
| <p className="text-xs text-text-muted"> | ||||||||||||||||||||||||||||||||||||||||||
| {keys.length}{" "} | ||||||||||||||||||||||||||||||||||||||||||
| {keys.length === 1 | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -605,6 +698,14 @@ export default function ApiManagerPageClient() { | |||||||||||||||||||||||||||||||||||||||||
| {t("createFirstKey")} | ||||||||||||||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
| ) : filteredKeys.length === 0 ? ( | ||||||||||||||||||||||||||||||||||||||||||
| <div className="text-center py-12 border border-dashed border-border rounded-lg"> | ||||||||||||||||||||||||||||||||||||||||||
| <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4"> | ||||||||||||||||||||||||||||||||||||||||||
| <span className="material-symbols-outlined text-[32px]">search_off</span> | ||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
| <p className="text-text-main font-medium mb-2">{t("emptyFilterTitle")}</p> | ||||||||||||||||||||||||||||||||||||||||||
| <Button onClick={handleClearFilters}>{t("emptyFilterClear")}</Button> | ||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||||||||||||||||||||
| <div className="flex flex-col border border-border rounded-lg overflow-hidden"> | ||||||||||||||||||||||||||||||||||||||||||
| {/* Table Header */} | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -618,7 +719,7 @@ export default function ApiManagerPageClient() { | |||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| {/* Table Rows */} | ||||||||||||||||||||||||||||||||||||||||||
| {keys.map((key) => { | ||||||||||||||||||||||||||||||||||||||||||
| {filteredKeys.map((key) => { | ||||||||||||||||||||||||||||||||||||||||||
| const stats = usageStats[key.id]; | ||||||||||||||||||||||||||||||||||||||||||
| const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0; | ||||||||||||||||||||||||||||||||||||||||||
| const hasConnectionRestrictions = | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
724
to
725
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These local variables duplicate logic now available in the
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| export const ACTIVE_ONLY_STORAGE_KEY = "omniroute-api-manager-active-only"; | ||
|
|
||
| interface StorageReader { | ||
| getItem(key: string): string | null; | ||
| } | ||
|
|
||
| interface StorageWriter extends StorageReader { | ||
| setItem(key: string, value: string): void; | ||
| removeItem(key: string): void; | ||
| } | ||
|
|
||
| function getBrowserStorage(): StorageWriter | null { | ||
| try { | ||
| return globalThis.localStorage ?? null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export function parseActiveOnlyPreference(value: string | null | undefined): boolean { | ||
| return value === "true"; | ||
| } | ||
|
|
||
| export function readActiveOnlyPreference( | ||
| storage: StorageReader | null = getBrowserStorage() | ||
| ): boolean { | ||
| if (!storage) return false; | ||
| return parseActiveOnlyPreference(storage.getItem(ACTIVE_ONLY_STORAGE_KEY)); | ||
| } | ||
|
|
||
| export function writeActiveOnlyPreference( | ||
| enabled: boolean, | ||
| storage: StorageWriter | null = getBrowserStorage() | ||
| ): void { | ||
| if (!storage) return; | ||
| if (enabled) { | ||
| storage.setItem(ACTIVE_ONLY_STORAGE_KEY, "true"); | ||
| return; | ||
| } | ||
| storage.removeItem(ACTIVE_ONLY_STORAGE_KEY); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| export type KeyStatus = "active" | "disabled" | "banned" | "expired"; | ||
|
|
||
| // "manage" scope = management key; "restricted" = has model/connection allowlists; | ||
| // "standard" = no manage scope and no allowlists. | ||
| // Note: a "manage" key with allowlists is still classified as "manage" (manage takes priority). | ||
| export type KeyType = "standard" | "manage" | "restricted"; | ||
|
|
||
| export interface ApiKeyShape { | ||
| isActive?: boolean; | ||
| isBanned?: boolean; | ||
| expiresAt?: string | null; | ||
| scopes?: string[]; | ||
| allowedModels?: string[] | null; | ||
| allowedConnections?: string[] | null; | ||
| } | ||
|
|
||
| export function isKeyActive(k: ApiKeyShape): boolean { | ||
| if (k.isBanned === true) return false; | ||
| if (k.isActive === false) return false; | ||
| if (k.expiresAt) { | ||
| return new Date(k.expiresAt).getTime() > Date.now(); | ||
| } | ||
| return true; | ||
| } | ||
|
Comment on lines
+17
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Refactor to reuse export function isKeyActive(k: ApiKeyShape): boolean {
if (k.isBanned === true) return false;
if (k.isActive === false) return false;
if (isExpired(k)) return false;
return true;
} |
||
|
|
||
| export function isExpired(k: ApiKeyShape): boolean { | ||
| if (!k.expiresAt) return false; | ||
| const ts = new Date(k.expiresAt).getTime(); | ||
| if (Number.isNaN(ts)) return false; | ||
| return ts < Date.now(); | ||
| } | ||
|
|
||
| export function isRestricted(k: ApiKeyShape): boolean { | ||
| const hasModelRestrictions = Array.isArray(k.allowedModels) && k.allowedModels.length > 0; | ||
| const hasConnectionRestrictions = | ||
| Array.isArray(k.allowedConnections) && k.allowedConnections.length > 0; | ||
| return hasModelRestrictions || hasConnectionRestrictions; | ||
| } | ||
|
|
||
| export function classifyKeyStatus(k: ApiKeyShape): KeyStatus { | ||
| if (k.isBanned === true) return "banned"; | ||
| if (isExpired(k)) return "expired"; | ||
| if (k.isActive === false) return "disabled"; | ||
| return "active"; | ||
| } | ||
|
|
||
| export function classifyKeyType(k: ApiKeyShape): KeyType { | ||
| if (Array.isArray(k.scopes) && k.scopes.includes("manage")) return "manage"; | ||
| if (isRestricted(k)) return "restricted"; | ||
| return "standard"; | ||
| } | ||
|
|
||
| export interface ApiKeyCounts { | ||
| total: number; | ||
| active: number; | ||
| disabled: number; | ||
| banned: number; | ||
| expired: number; | ||
| standard: number; | ||
| manage: number; | ||
| restricted: number; | ||
| } | ||
|
|
||
| export function computeApiKeyCounts(keys: ApiKeyShape[]): ApiKeyCounts { | ||
| const counts: ApiKeyCounts = { | ||
| total: keys.length, | ||
| active: 0, | ||
| disabled: 0, | ||
| banned: 0, | ||
| expired: 0, | ||
| standard: 0, | ||
| manage: 0, | ||
| restricted: 0, | ||
| }; | ||
|
|
||
| for (const k of keys) { | ||
| const status = classifyKeyStatus(k); | ||
| counts[status] += 1; | ||
|
|
||
| const type = classifyKeyType(k); | ||
| counts[type] += 1; | ||
| } | ||
|
|
||
| return counts; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current persistence logic for
activeOnlyhas a race condition. On the initial render,activeOnlyisfalse. The seconduseEffect(line 151) will trigger and writefalsetolocalStoragebefore the firstuseEffect(line 147) can read the saved value and update the state. This effectively overwrites the user's preference with the default value on every page load.Consider using a
refto skip the initial write or initializing the state fromlocalStorageusing a lazy initializer (though the latter requires careful handling of hydration in Next.js).