diff --git a/apps/dashboard/app/(app)/apis/_components/api-list-card.tsx b/apps/dashboard/app/(app)/apis/_components/api-list-card.tsx
index 2a58558456..fd590f1136 100644
--- a/apps/dashboard/app/(app)/apis/_components/api-list-card.tsx
+++ b/apps/dashboard/app/(app)/apis/_components/api-list-card.tsx
@@ -12,7 +12,7 @@ type Props = {
};
export const ApiListCard = ({ api }: Props) => {
- const { timeseries, isLoading, isError } = useFetchVerificationTimeseries(api.keyspaceId);
+ const { timeseries, isError } = useFetchVerificationTimeseries(api.keyspaceId);
const passed = timeseries?.reduce((acc, crr) => acc + crr.success, 0) ?? 0;
const blocked = timeseries?.reduce((acc, crr) => acc + crr.error, 0) ?? 0;
@@ -26,7 +26,8 @@ export const ApiListCard = ({ api }: Props) => {
chart={
))}
-
+
Showing {apiList.length} of {total} APIs
diff --git a/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx b/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx
index ab4881fc4c..e0a3acf767 100644
--- a/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx
+++ b/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx
@@ -36,6 +36,7 @@ export const CreateApiButton = ({
}: React.ButtonHTMLAttributes
& Props) => {
const [isOpen, setIsOpen] = useState(defaultOpen ?? false);
const router = useRouter();
+ const { api } = trpc.useUtils();
const {
register,
@@ -50,6 +51,7 @@ export const CreateApiButton = ({
async onSuccess(res) {
toast.success("Your API has been created");
await revalidate("/apis");
+ api.overview.query.invalidate();
router.push(`/apis/${res.id}`);
setIsOpen(false);
},
diff --git a/apps/dashboard/app/(app)/apis/actions.ts b/apps/dashboard/app/(app)/apis/actions.ts
index a7eb0dd471..0dbacf5fc4 100644
--- a/apps/dashboard/app/(app)/apis/actions.ts
+++ b/apps/dashboard/app/(app)/apis/actions.ts
@@ -19,6 +19,7 @@ export async function fetchApiOverview({
.where(and(eq(schema.apis.workspaceId, workspaceId), isNull(schema.apis.deletedAtM)));
const total = Number(totalResult[0]?.count || 0);
+ // Updated query to include keyAuth and fetch actual keys
const query = db.query.apis.findMany({
where: (table, { and, eq, isNull, gt }) => {
const conditions = [eq(table.workspaceId, workspaceId), isNull(table.deletedAtM)];
@@ -30,6 +31,7 @@ export async function fetchApiOverview({
with: {
keyAuth: {
columns: {
+ id: true, // Include the keyspace ID
sizeApprox: true,
},
},
@@ -44,7 +46,23 @@ export async function fetchApiOverview({
const nextCursor =
hasMore && apiItems.length > 0 ? { id: apiItems[apiItems.length - 1].id } : undefined;
- const apiList = await apiItemsWithApproxKeyCounts(apiItems);
+ // Transform the data to include key information
+ const apiList = await Promise.all(
+ apiItems.map(async (api) => {
+ const keyspaceId = api.keyAuth?.id || null;
+
+ return {
+ id: api.id,
+ name: api.name,
+ keyspaceId,
+ keys: [
+ {
+ count: api.keyAuth?.sizeApprox || 0,
+ },
+ ],
+ };
+ }),
+ );
return {
apiList,
diff --git a/apps/dashboard/app/(app)/layout.tsx b/apps/dashboard/app/(app)/layout.tsx
index 576e3382e4..7ff88c0f10 100644
--- a/apps/dashboard/app/(app)/layout.tsx
+++ b/apps/dashboard/app/(app)/layout.tsx
@@ -1,4 +1,4 @@
-import { AppSidebar } from "@/components/app-sidebar";
+import { AppSidebar } from "@/components/navigation/sidebar/app-sidebar";
import { SidebarMobile } from "@/components/navigation/sidebar/sidebar-mobile";
import { SidebarProvider } from "@/components/ui/sidebar";
import { getOrgId } from "@/lib/auth";
diff --git a/apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx b/apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx
index 378d9442e9..456eb0d26d 100644
--- a/apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx
+++ b/apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx
@@ -1,5 +1,6 @@
"use client";
+import { revalidate } from "@/app/actions";
import { DialogContainer } from "@/components/dialog-container";
import { NavbarActionButton } from "@/components/navigation/action-button";
import { toast } from "@/components/ui/toaster";
@@ -32,6 +33,8 @@ export const CreateNamespaceButton = ({
}: React.ButtonHTMLAttributes) => {
const [isOpen, setIsOpen] = useState(false);
+ const { ratelimit } = trpc.useUtils();
+
const {
register,
handleSubmit,
@@ -44,9 +47,11 @@ export const CreateNamespaceButton = ({
const router = useRouter();
const create = trpc.ratelimit.namespace.create.useMutation({
- onSuccess(res) {
+ async onSuccess(res) {
toast.success("Your Namespace has been created");
router.refresh();
+ await revalidate("/ratelimits");
+ ratelimit.namespace.query.invalidate();
router.push(`/ratelimits/${res.id}`);
setIsOpen(false);
},
diff --git a/apps/dashboard/app/(app)/settings/root-keys/new/client.tsx b/apps/dashboard/app/(app)/settings/root-keys/new/client.tsx
index 8743097a9a..f464733348 100644
--- a/apps/dashboard/app/(app)/settings/root-keys/new/client.tsx
+++ b/apps/dashboard/app/(app)/settings/root-keys/new/client.tsx
@@ -279,7 +279,7 @@ export const Client: React.FC = ({ apis }) => {
}
}}
>
-
+
Your API Key
diff --git a/apps/dashboard/components/app-sidebar.tsx b/apps/dashboard/components/app-sidebar.tsx
index 73bb2c889b..80092cb237 100644
--- a/apps/dashboard/components/app-sidebar.tsx
+++ b/apps/dashboard/components/app-sidebar.tsx
@@ -238,7 +238,7 @@ const FlatNavItem = memo(({ item }: { item: NavItem }) => {
isActive={item.active}
className={getButtonStyles(item.active, showLoader)}
>
- {showLoader ? : }
+ {showLoader ? : Icon ? : null}
{item.label}
@@ -264,7 +264,7 @@ const NestedNavItem = memo(({ item }: { item: NavItem & { items?: NavItem[] } })
isActive={item.active}
className={getButtonStyles(item.active, showLoader)}
>
- {showLoader ? : }
+ {showLoader ? : Icon ? : null}
{item.label}
@@ -336,6 +336,8 @@ const ToggleSidebarButton = memo(
toggleNavItem: NavItem;
toggleSidebar: () => void;
}) => {
+ const Icon = toggleNavItem.icon;
+
return (
-
+ {Icon && }
{toggleNavItem.label}
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/animated-loading-spinner.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/animated-loading-spinner.tsx
new file mode 100644
index 0000000000..34f9f30a87
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/animated-loading-spinner.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from "react";
+import { getPathForSegment } from "./utils";
+
+// Define style ID to check for duplicates
+const STYLE_ID = "animated-loading-spinner-styles";
+
+// Add styles only once when module is loaded
+if (typeof document !== "undefined" && !document.getElementById(STYLE_ID)) {
+ const style = document.createElement("style");
+ style.id = STYLE_ID;
+ style.textContent = `
+ @media (prefers-reduced-motion: reduce) {
+ [data-prefers-reduced-motion="respect-motion-preference"] {
+ animation: none !important;
+ transition: none !important;
+ }
+ }
+
+ @keyframes spin-slow {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
+ .animate-spin-slow {
+ animation: spin-slow 1.5s linear infinite;
+ }
+ `;
+ document.head.appendChild(style);
+}
+
+const SEGMENTS = [
+ "segment-1", // Right top
+ "segment-2", // Right
+ "segment-3", // Right bottom
+ "segment-4", // Bottom
+ "segment-5", // Left bottom
+ "segment-6", // Left
+ "segment-7", // Left top
+ "segment-8", // Top
+];
+
+export const AnimatedLoadingSpinner = () => {
+ const [segmentIndex, setSegmentIndex] = useState(0);
+
+ useEffect(() => {
+ // Animate the segments in sequence
+ const timer = setInterval(() => {
+ setSegmentIndex((prevIndex) => (prevIndex + 1) % SEGMENTS.length);
+ }, 125); // 125ms per segment = 1s for full rotation
+
+ return () => clearInterval(timer);
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx
new file mode 100644
index 0000000000..556737b4b0
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx
@@ -0,0 +1,57 @@
+import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
+import { useDelayLoader } from "@/hooks/use-delay-loader";
+import { useRouter } from "next/navigation";
+import { useTransition } from "react";
+import type { NavItem } from "../../../workspace-navigations";
+import { NavLink } from "../nav-link";
+import { AnimatedLoadingSpinner } from "./animated-loading-spinner";
+import { getButtonStyles } from "./utils";
+
+export const FlatNavItem = ({
+ item,
+ onLoadMore,
+}: {
+ item: NavItem & { loadMoreAction?: boolean };
+ onLoadMore?: () => void;
+}) => {
+ const [isPending, startTransition] = useTransition();
+ const showLoader = useDelayLoader(isPending);
+ const router = useRouter();
+ const Icon = item.icon;
+
+ const isLoadMoreButton = item.loadMoreAction === true;
+
+ const handleClick = () => {
+ if (isLoadMoreButton && onLoadMore) {
+ onLoadMore();
+ return;
+ }
+
+ if (!item.external) {
+ startTransition(() => {
+ router.push(item.href);
+ });
+ }
+ };
+
+ return (
+
+
+
+ {showLoader ? : Icon ? : null}
+ {item.label}
+ {item.tag && {item.tag}
}
+
+
+
+ );
+};
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx
new file mode 100644
index 0000000000..71bffed86f
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx
@@ -0,0 +1,19 @@
+import type { NavItem } from "../../../workspace-navigations";
+import { FlatNavItem } from "./flat-nav-item";
+import { NestedNavItem } from "./nested-nav-item";
+
+export const NavItems = ({
+ item,
+ onLoadMore,
+}: {
+ item: NavItem & {
+ items?: (NavItem & { loadMoreAction?: boolean })[];
+ loadMoreAction?: boolean;
+ };
+ onLoadMore?: () => void;
+}) => {
+ if (!item.items || item.items.length === 0) {
+ return ;
+ }
+ return ;
+};
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx
new file mode 100644
index 0000000000..f0809932e9
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx
@@ -0,0 +1,197 @@
+import {
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubItem,
+} from "@/components/ui/sidebar";
+import { useDelayLoader } from "@/hooks/use-delay-loader";
+import { cn } from "@/lib/utils";
+import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
+import { CaretRight } from "@unkey/icons";
+import { useRouter } from "next/navigation";
+import { useState, useTransition } from "react";
+import type { NavItem } from "../../../workspace-navigations";
+import { NavLink } from "../nav-link";
+import { AnimatedLoadingSpinner } from "./animated-loading-spinner";
+import { getButtonStyles } from "./utils";
+
+export const NestedNavItem = ({
+ item,
+ onLoadMore,
+ depth = 0,
+ maxDepth = 1,
+}: {
+ item: NavItem;
+ onLoadMore?: () => void;
+ depth?: number;
+ maxDepth?: number;
+}) => {
+ const [parentIsPending, startParentTransition] = useTransition();
+ const showParentLoader = useDelayLoader(parentIsPending);
+ const router = useRouter();
+ const Icon = item.icon;
+ const [subPending, setSubPending] = useState>({});
+ const [isOpen, setIsOpen] = useState(hasActiveChild(item));
+
+ function hasActiveChild(navItem: NavItem): boolean {
+ if (!navItem.items) {
+ return false;
+ }
+ return navItem.items.some((child) => child.active || hasActiveChild(child));
+ }
+
+ const handleMenuItemClick = (e: React.MouseEvent) => {
+ // If the item has children, toggle the open state
+ if (item.items && item.items.length > 0) {
+ // Check if we're closing or opening
+ const willClose = isOpen;
+
+ // Toggle the open state
+ setIsOpen(!isOpen);
+
+ // If we're closing, prevent navigation
+ if (willClose) {
+ e.preventDefault();
+ return;
+ }
+ }
+
+ // If the item has a href, navigate to it
+ if (item.href) {
+ if (!item.external) {
+ // Show loading state ONLY for parent
+ startParentTransition(() => {
+ router.push(item.href);
+ });
+ } else {
+ // For external links, open in new tab
+ window.open(item.href, "_blank");
+ }
+ }
+ };
+
+ // Render a sub-item, potentially recursively if it has children
+ const renderSubItem = (subItem: NavItem, index: number) => {
+ const SubIcon = subItem.icon;
+ const isLoadMoreButton = subItem.loadMoreAction === true;
+ const hasChildren = subItem.items && subItem.items.length > 0;
+
+ // If this subitem has children and is not at max depth, render it as another NestedNavItem
+ if (hasChildren && depth < maxDepth) {
+ return (
+
+
+
+ );
+ }
+
+ // Otherwise render as a regular sub-item
+ const handleSubItemClick = () => {
+ if (isLoadMoreButton && onLoadMore) {
+ onLoadMore();
+ return;
+ }
+
+ if (!subItem.external && subItem.href) {
+ // Track loading state for this specific sub-item
+ const updatedPending = { ...subPending };
+ updatedPending[subItem.label as string] = true;
+ setSubPending(updatedPending);
+
+ // Use a separate transition for sub-items
+ // This prevents parent from showing loader
+ const subItemTransition = () => {
+ router.push(subItem.href);
+ // Reset loading state after transition
+ setTimeout(() => {
+ const resetPending = { ...subPending };
+ resetPending[subItem.label as string] = false;
+ setSubPending(resetPending);
+ }, 300);
+ };
+
+ // Execute transition without affecting parent's isPending state
+ subItemTransition();
+ }
+ };
+
+ return (
+
+
+
+ {SubIcon ? (
+ subPending[subItem.label as string] ? (
+
+ ) : (
+
+ )
+ ) : null}
+ {subItem.label}
+ {subItem.tag && {subItem.tag}
}
+
+
+
+ );
+ };
+
+ return (
+
+
+
+ {Icon ? showParentLoader ? : : null}
+ {item.label}
+ {item.tag && {item.tag}
}
+ {/* Chevron icon to indicate there are children */}
+ {item.items && item.items.length > 0 && (
+
+
+
+ )}
+
+ {item.items && item.items.length > 0 && (
+
+
+ {item.items.map((subItem, index) => renderSubItem(subItem, index))}
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/toggle-sidebar-button.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/toggle-sidebar-button.tsx
new file mode 100644
index 0000000000..88417d720d
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/toggle-sidebar-button.tsx
@@ -0,0 +1,25 @@
+import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
+import type { NavItem } from "../../../workspace-navigations";
+import { getButtonStyles } from "./utils";
+
+export const ToggleSidebarButton = ({
+ toggleNavItem,
+ toggleSidebar,
+}: {
+ toggleNavItem: NavItem;
+ toggleSidebar: () => void;
+}) => {
+ return (
+
+
+ {toggleNavItem.icon && }
+ {toggleNavItem.label}
+
+
+ );
+};
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/utils.ts b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/utils.ts
new file mode 100644
index 0000000000..74f9f8aa80
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/utils.ts
@@ -0,0 +1,25 @@
+import { cn } from "@unkey/ui/src/lib/utils";
+
+export const getButtonStyles = (isActive?: boolean, showLoader?: boolean) => {
+ return cn(
+ "flex items-center group text-[13px] font-medium text-accent-12 hover:bg-grayA-3 hover:text-accent-12 justify-start active:border focus:ring-2 w-full text-left",
+ "rounded-lg transition-colors focus-visible:ring-1 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 disabled:cursor-not-allowed outline-none",
+ "focus:border-grayA-12 focus:ring-gray-6 focus-visible:outline-none focus:ring-offset-0 drop-shadow-button",
+ isActive ? "bg-grayA-3 text-accent-12" : "[&_svg]:text-gray-9",
+ showLoader ? "bg-grayA-3 [&_svg]:text-accent-12" : "",
+ );
+};
+
+export function getPathForSegment(index: number) {
+ const paths = [
+ "M13.162,3.82c-.148,0-.299-.044-.431-.136-.784-.552-1.662-.915-2.61-1.08-.407-.071-.681-.459-.61-.867,.071-.408,.459-.684,.868-.61,1.167,.203,2.248,.65,3.216,1.33,.339,.238,.42,.706,.182,1.045-.146,.208-.378,.319-.614,.319Z",
+ "M16.136,8.5c-.357,0-.675-.257-.738-.622-.163-.942-.527-1.82-1.082-2.608-.238-.339-.157-.807,.182-1.045,.34-.239,.809-.156,1.045,.182,.683,.97,1.132,2.052,1.334,3.214,.07,.408-.203,.796-.611,.867-.043,.008-.086,.011-.129,.011Z",
+ "M14.93,13.913c-.148,0-.299-.044-.431-.137-.339-.238-.42-.706-.182-1.045,.551-.784,.914-1.662,1.078-2.609,.071-.408,.466-.684,.867-.611,.408,.071,.682,.459,.611,.867-.203,1.167-.65,2.25-1.33,3.216-.146,.208-.378,.318-.614,.318Z",
+ "M10.249,16.887c-.357,0-.675-.257-.738-.621-.07-.408,.202-.797,.61-.868,.945-.165,1.822-.529,2.608-1.082,.34-.238,.807-.156,1.045,.182,.238,.338,.157,.807-.182,1.045-.968,.682-2.05,1.13-3.214,1.333-.044,.008-.087,.011-.13,.011Z",
+ "M7.751,16.885c-.043,0-.086-.003-.13-.011-1.167-.203-2.249-.651-3.216-1.33-.339-.238-.42-.706-.182-1.045,.236-.339,.702-.421,1.045-.183,.784,.551,1.662,.915,2.61,1.08,.408,.071,.681,.459,.61,.868-.063,.364-.381,.621-.738,.621Z",
+ "M3.072,13.911c-.236,0-.469-.111-.614-.318-.683-.97-1.132-2.052-1.334-3.214-.07-.408,.203-.796,.611-.867,.403-.073,.796,.202,.867,.61,.163,.942,.527,1.82,1.082,2.608,.238,.339,.157,.807-.182,1.045-.131,.092-.282,.137-.431,.137Z",
+ "M1.866,8.5c-.043,0-.086-.003-.129-.011-.408-.071-.682-.459-.611-.867,.203-1.167,.65-2.25,1.33-3.216,.236-.339,.703-.422,1.045-.182,.339,.238,.42,.706,.182,1.045-.551,.784-.914,1.662-1.078,2.609-.063,.365-.381,.622-.738,.622Z",
+ "M4.84,3.821c-.236,0-.468-.111-.614-.318-.238-.338-.157-.807,.182-1.045,.968-.682,2.05-1.13,3.214-1.333,.41-.072,.797,.202,.868,.61,.07,.408-.202,.797-.61,.868-.945,.165-1.822,.529-2.608,1.082-.131,.092-.282,.137-.431,.137Z",
+ ];
+ return paths[index];
+}
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-link.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-link.tsx
new file mode 100644
index 0000000000..02305cdf9d
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-link.tsx
@@ -0,0 +1,35 @@
+import Link from "next/link";
+
+export const NavLink = ({
+ href,
+ external,
+ onClick,
+ children,
+ isLoadMoreButton = false,
+}: {
+ href: string;
+ external?: boolean;
+ onClick?: () => void;
+ children: React.ReactNode;
+ isLoadMoreButton?: boolean;
+}) => {
+ // For the load more button, we use a button instead of a link
+ if (isLoadMoreButton) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-api-navigation.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-api-navigation.tsx
new file mode 100644
index 0000000000..01ee723b00
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-api-navigation.tsx
@@ -0,0 +1,118 @@
+"use client";
+import type { NavItem } from "@/components/navigation/sidebar/workspace-navigations";
+import { trpc } from "@/lib/trpc/client";
+import { ArrowOppositeDirectionY, Gear } from "@unkey/icons";
+import { Key } from "lucide-react";
+import { useSelectedLayoutSegments } from "next/navigation";
+import { useMemo } from "react";
+
+const DEFAULT_LIMIT = 10;
+
+export const useApiNavigation = (baseNavItems: NavItem[]) => {
+ const segments = useSelectedLayoutSegments() ?? [];
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
+ trpc.api.overview.query.useInfiniteQuery(
+ {
+ limit: DEFAULT_LIMIT,
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ );
+
+ // Convert API data to navigation items with sub-items for settings and keys
+ const apiNavItems = useMemo(() => {
+ if (!data?.pages) {
+ return [];
+ }
+
+ return data.pages.flatMap((page) =>
+ page.apiList.map((api) => {
+ const currentApiActive = segments.at(0) === "apis" && segments.at(1) === api.id;
+ const isExactlyApiRoot = currentApiActive && segments.length === 2;
+
+ const settingsItem: NavItem = {
+ icon: Gear,
+ href: `/apis/${api.id}/settings`,
+ label: "Settings",
+ active: currentApiActive && segments.at(2) === "settings",
+ };
+
+ const overviewItem: NavItem = {
+ icon: ArrowOppositeDirectionY,
+ href: `/apis/${api.id}`,
+ label: "Requests",
+ active: isExactlyApiRoot || (currentApiActive && !segments.at(2)),
+ };
+
+ const subItems: NavItem[] = [overviewItem, settingsItem];
+
+ if (api.keyspaceId) {
+ const keysItem: NavItem = {
+ icon: Key,
+ href: `/apis/${api.id}/keys/${api.keyspaceId}`,
+ label: "Keys",
+ active: currentApiActive && segments.at(2) === "keys",
+ };
+
+ subItems.push(keysItem);
+ }
+
+ // Create the main API nav item with proper icon setup
+ const apiNavItem: NavItem = {
+ // This is critical - must provide some icon to ensure chevron renders
+ icon: null,
+ href: `/apis/${api.id}`,
+ label: api.name,
+ active: currentApiActive,
+ // Always set showSubItems to true to ensure chevron appears
+ showSubItems: true,
+ // Include sub-items
+ items: subItems,
+ };
+
+ return apiNavItem;
+ }),
+ );
+ }, [data?.pages, segments]);
+
+ const enhancedNavItems = useMemo(() => {
+ const items = [...baseNavItems];
+ const apisItemIndex = items.findIndex((item) => item.href === "/apis");
+
+ if (apisItemIndex !== -1) {
+ const apisItem = { ...items[apisItemIndex] };
+ // Always set showSubItems to true for the APIs section
+ apisItem.showSubItems = true;
+ apisItem.items = [...(apisItem.items || []), ...apiNavItems];
+
+ if (hasNextPage) {
+ apisItem.items?.push({
+ icon: () => null,
+ href: "#load-more",
+ //@ts-expect-error will fix that later
+ label: More
,
+ active: false,
+ loadMoreAction: true,
+ });
+ }
+
+ items[apisItemIndex] = apisItem;
+ }
+
+ return items;
+ }, [baseNavItems, apiNavItems, hasNextPage]);
+
+ const loadMore = () => {
+ if (!isFetchingNextPage && hasNextPage) {
+ fetchNextPage();
+ }
+ };
+
+ return {
+ enhancedNavItems,
+ isLoading,
+ loadMore,
+ };
+};
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx
new file mode 100644
index 0000000000..99b5be86da
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import type { NavItem } from "@/components/navigation/sidebar/workspace-navigations";
+import { trpc } from "@/lib/trpc/client";
+import {
+ ArrowDottedRotateAnticlockwise,
+ ArrowOppositeDirectionY,
+ Gear,
+ Layers3,
+} from "@unkey/icons";
+import { useSelectedLayoutSegments } from "next/navigation";
+import { useMemo } from "react";
+
+const DEFAULT_LIMIT = 10;
+
+export const useRatelimitNavigation = (baseNavItems: NavItem[]) => {
+ const segments = useSelectedLayoutSegments() ?? [];
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
+ trpc.ratelimit.namespace.query.useInfiniteQuery(
+ {
+ limit: DEFAULT_LIMIT,
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ );
+
+ // Convert ratelimit namespaces data to navigation items with sub-items
+ const ratelimitNavItems = useMemo(() => {
+ if (!data?.pages) {
+ return [];
+ }
+
+ return data.pages.flatMap((page) =>
+ page.namespaceList.map((namespace) => {
+ const currentNamespaceActive =
+ segments.at(0) === "ratelimits" && segments.at(1) === namespace.id;
+
+ const isExactlyRatelimitRoot = currentNamespaceActive && segments.length === 2;
+
+ // Create sub-items for logs, settings, and overrides
+ const subItems: NavItem[] = [
+ {
+ icon: ArrowOppositeDirectionY,
+ href: `/ratelimits/${namespace.id}`,
+ label: "Requests",
+ active: isExactlyRatelimitRoot || (currentNamespaceActive && !segments.at(2)),
+ },
+ {
+ icon: Layers3,
+ href: `/ratelimits/${namespace.id}/logs`,
+ label: "Logs",
+ active: currentNamespaceActive && segments.at(2) === "logs",
+ },
+ {
+ icon: Gear,
+ href: `/ratelimits/${namespace.id}/settings`,
+ label: "Settings",
+ active: currentNamespaceActive && segments.at(2) === "settings",
+ },
+ {
+ icon: ArrowDottedRotateAnticlockwise,
+ href: `/ratelimits/${namespace.id}/overrides`,
+ label: "Overrides",
+ active: currentNamespaceActive && segments.at(2) === "overrides",
+ },
+ ];
+
+ // Create the main namespace nav item
+ const namespaceNavItem: NavItem = {
+ icon: null,
+ href: `/ratelimits/${namespace.id}`,
+ label: namespace.name,
+ active: currentNamespaceActive,
+ // Include sub-items
+ items: subItems,
+ };
+
+ return namespaceNavItem;
+ }),
+ );
+ }, [data?.pages, segments]);
+
+ const enhancedNavItems = useMemo(() => {
+ const items = [...baseNavItems];
+ const ratelimitsItemIndex = items.findIndex((item) => item.href === "/ratelimits");
+
+ if (ratelimitsItemIndex !== -1) {
+ const ratelimitsItem = { ...items[ratelimitsItemIndex] };
+ ratelimitsItem.items = [...(ratelimitsItem.items || []), ...ratelimitNavItems];
+
+ if (hasNextPage) {
+ ratelimitsItem.items?.push({
+ icon: () => null,
+ href: "#load-more-ratelimits",
+ label: "More",
+ active: false,
+ loadMoreAction: true,
+ });
+ }
+
+ items[ratelimitsItemIndex] = ratelimitsItem;
+ }
+
+ return items;
+ }, [baseNavItems, ratelimitNavItems, hasNextPage]);
+
+ const loadMore = () => {
+ if (!isFetchingNextPage && hasNextPage) {
+ fetchNextPage();
+ }
+ };
+
+ return {
+ enhancedNavItems,
+ isLoading,
+ loadMore,
+ };
+};
diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx
new file mode 100644
index 0000000000..07598deec1
--- /dev/null
+++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx
@@ -0,0 +1,121 @@
+"use client";
+import { WorkspaceSwitcher } from "@/components/navigation/sidebar/team-switcher";
+import { UserButton } from "@/components/navigation/sidebar/user-button";
+import {
+ type NavItem,
+ createWorkspaceNavigation,
+ resourcesNavigation,
+} from "@/components/navigation/sidebar/workspace-navigations";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarHeader,
+ SidebarMenu,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import type { Workspace } from "@/lib/db";
+import { cn } from "@/lib/utils";
+import { SidebarLeftHide, SidebarLeftShow } from "@unkey/icons";
+import { useSelectedLayoutSegments } from "next/navigation";
+import { useCallback, useMemo } from "react";
+import { NavItems } from "./components/nav-items";
+import { ToggleSidebarButton } from "./components/nav-items/toggle-sidebar-button";
+import { useApiNavigation } from "./hooks/use-api-navigation";
+import { useRatelimitNavigation } from "./hooks/use-ratelimit-navigation";
+
+export function AppSidebar({
+ ...props
+}: React.ComponentProps & { workspace: Workspace }) {
+ const segments = useSelectedLayoutSegments() ?? [];
+
+ // Create base navigation items
+ const baseNavItems = useMemo(
+ () => createWorkspaceNavigation(props.workspace, segments),
+ [props.workspace, segments],
+ );
+
+ const { enhancedNavItems: apiAddedNavItems, loadMore: loadMoreApis } =
+ useApiNavigation(baseNavItems);
+
+ const { enhancedNavItems: ratelimitAddedNavItems, loadMore: loadMoreRatelimits } =
+ useRatelimitNavigation(apiAddedNavItems);
+
+ const handleLoadMore = useCallback(
+ (item: NavItem & { loadMoreAction?: boolean }) => {
+ // If this is the ratelimit "load more" item
+ if (item.href === "#load-more-ratelimits") {
+ loadMoreRatelimits();
+ } else {
+ // Default to API loading (existing behavior)
+ loadMoreApis();
+ }
+ },
+ [loadMoreApis, loadMoreRatelimits],
+ );
+
+ const toggleNavItem: NavItem = useMemo(
+ () => ({
+ label: "Toggle Sidebar",
+ href: "#",
+ icon: SidebarLeftShow,
+ active: false,
+ tooltip: "Toggle Sidebar",
+ }),
+ [],
+ );
+
+ const { state, isMobile, toggleSidebar } = useSidebar();
+ const isCollapsed = state === "collapsed";
+
+ const headerContent = useMemo(
+ () => (
+
+
+ {state !== "collapsed" && !isMobile && (
+
+ )}
+
+ ),
+ [isCollapsed, props.workspace, state, isMobile, toggleSidebar],
+ );
+
+ const resourceNavItems = useMemo(() => resourcesNavigation, []);
+
+ return (
+
+ {headerContent}
+
+
+
+ {/* Toggle button as NavItem */}
+ {state === "collapsed" && (
+
+ )}
+ {ratelimitAddedNavItems.map((item) => (
+ handleLoadMore(item)}
+ />
+ ))}
+ {resourceNavItems.map((item) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx b/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx
index 93b85eb335..043a3e97d8 100644
--- a/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx
+++ b/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx
@@ -16,13 +16,16 @@ import { cn } from "../../../lib/utils";
export type NavItem = {
disabled?: boolean;
tooltip?: string;
- icon: React.ElementType;
+ icon: React.ElementType | null;
href: string;
external?: boolean;
label: string;
active?: boolean;
tag?: React.ReactNode;
hidden?: boolean;
+ items?: NavItem[];
+ loadMoreAction?: boolean;
+ showSubItems?: boolean;
};
const DiscordIcon = () => (
@@ -63,6 +66,7 @@ export const createWorkspaceNavigation = (
href: "/apis",
label: "APIs",
active: segments.at(0) === "apis",
+ showSubItems: false,
},
{
icon: Gauge,
@@ -75,6 +79,20 @@ export const createWorkspaceNavigation = (
label: "Authorization",
href: "/authorization/roles",
active: segments.some((s) => s === "authorization"),
+ items: [
+ {
+ icon: null,
+ label: "Roles",
+ href: "/authorization/roles",
+ active: segments.some((s) => s === "roles"),
+ },
+ {
+ icon: null,
+ label: "Permissions",
+ href: "/authorization/permissions",
+ active: segments.some((s) => s === "permissions"),
+ },
+ ],
},
{
@@ -95,7 +113,6 @@ export const createWorkspaceNavigation = (
href: "/logs",
label: "Logs",
active: segments.at(0) === "logs",
- tag: ,
},
{
icon: Sparkle3,
@@ -117,6 +134,38 @@ export const createWorkspaceNavigation = (
href: "/settings/general",
label: "Settings",
active: segments.at(0) === "settings",
+ items: [
+ {
+ icon: null,
+ href: "/settings/team",
+ label: "Team",
+ active: segments.some((s) => s === "team"),
+ },
+ {
+ icon: null,
+ href: "/settings/root-keys",
+ label: "Root Keys",
+ active: segments.some((s) => s === "root-keys"),
+ },
+ {
+ icon: null,
+ href: "/settings/billing",
+ label: "Billing",
+ active: segments.some((s) => s === "billing"),
+ },
+ {
+ icon: null,
+ href: "/settings/vercel",
+ label: "Vercel Integration",
+ active: segments.some((s) => s === "vercel"),
+ },
+ {
+ icon: null,
+ href: "/settings/user",
+ label: "User",
+ active: segments.some((s) => s === "user"),
+ },
+ ],
},
].filter((n) => !n.hidden);
};
diff --git a/apps/dashboard/components/stats-card/components/chart/stats-chart.tsx b/apps/dashboard/components/stats-card/components/chart/stats-chart.tsx
index d81956cd91..b32be9f4a5 100644
--- a/apps/dashboard/components/stats-card/components/chart/stats-chart.tsx
+++ b/apps/dashboard/components/stats-card/components/chart/stats-chart.tsx
@@ -43,7 +43,7 @@ export function StatsTimeseriesBarChart({
}
return (
-
+
dataMax * 1.3]} hide />
diff --git a/apps/dashboard/components/ui/sidebar.tsx b/apps/dashboard/components/ui/sidebar.tsx
index aede6633a4..936672a232 100644
--- a/apps/dashboard/components/ui/sidebar.tsx
+++ b/apps/dashboard/components/ui/sidebar.tsx
@@ -410,7 +410,7 @@ const SidebarMenuItem = React.forwardRefspan:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 px-[10px] text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 pr-0",
{
variants: {
variant: {
@@ -522,20 +522,28 @@ const SidebarMenuAction = React.forwardRef<
});
SidebarMenuAction.displayName = "SidebarMenuAction";
-const SidebarMenuSub = React.forwardRef>(
- ({ className, ...props }, ref) => (
+const SidebarMenuSub = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul"> & { depth?: number; maxDepth?: number }
+>(({ className, depth = 0, maxDepth = 2, ...props }, ref) => {
+ // Check if this is the final depth level
+ const isFinalDepth = depth >= maxDepth;
+
+ return (
- ),
-);
+ );
+});
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef>(
diff --git a/apps/dashboard/lib/trpc/routers/api/overview/query-overview/schemas.ts b/apps/dashboard/lib/trpc/routers/api/overview/query-overview/schemas.ts
index 3906a5fa49..723d356149 100644
--- a/apps/dashboard/lib/trpc/routers/api/overview/query-overview/schemas.ts
+++ b/apps/dashboard/lib/trpc/routers/api/overview/query-overview/schemas.ts
@@ -4,12 +4,14 @@ export const apiOverview = z.object({
id: z.string(),
name: z.string(),
keyspaceId: z.string().nullable(),
+ // For backward compatibility
keys: z.array(
z.object({
count: z.number(),
}),
),
});
+
export type ApiOverview = z.infer;
const Cursor = z.object({
diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts
index 77da25f679..712f428a8d 100644
--- a/apps/dashboard/lib/trpc/routers/index.ts
+++ b/apps/dashboard/lib/trpc/routers/index.ts
@@ -46,6 +46,7 @@ import { deleteNamespace } from "./ratelimit/deleteNamespace";
import { deleteOverride } from "./ratelimit/deleteOverride";
import { ratelimitLlmSearch } from "./ratelimit/llm-search";
import { searchNamespace } from "./ratelimit/namespace-search";
+import { queryRatelimitNamespaces } from "./ratelimit/query-keys";
import { queryRatelimitLatencyTimeseries } from "./ratelimit/query-latency-timeseries";
import { queryRatelimitLogs } from "./ratelimit/query-logs";
import { queryRatelimitOverviewLogs } from "./ratelimit/query-overview-logs";
@@ -157,6 +158,7 @@ export const router = t.router({
}),
}),
namespace: t.router({
+ query: queryRatelimitNamespaces,
search: searchNamespace,
create: createNamespace,
update: t.router({
diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/query-keys/index.ts b/apps/dashboard/lib/trpc/routers/ratelimit/query-keys/index.ts
new file mode 100644
index 0000000000..9dfbe4f9eb
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/ratelimit/query-keys/index.ts
@@ -0,0 +1,91 @@
+import { and, db, eq, isNull, schema, sql } from "@/lib/db";
+import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc";
+import { TRPCError } from "@trpc/server";
+import {
+ type RatelimitNamespacesResponse,
+ queryRatelimitNamespacesPayload,
+ ratelimitNamespacesResponse,
+} from "./schemas";
+
+export const queryRatelimitNamespaces = t.procedure
+ .use(requireUser)
+ .use(requireWorkspace)
+ .use(withRatelimit(ratelimit.read))
+ .input(queryRatelimitNamespacesPayload)
+ .output(ratelimitNamespacesResponse)
+ .query(async ({ ctx, input }) => {
+ try {
+ const result = await fetchRatelimitNamespaces({
+ workspaceId: ctx.workspace.id,
+ limit: input.limit,
+ cursor: input.cursor,
+ });
+ return result;
+ } catch (error) {
+ console.error(
+ "Something went wrong when fetching ratelimit namespaces",
+ JSON.stringify(error),
+ );
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to fetch ratelimit namespaces",
+ });
+ }
+ });
+
+export type RatelimitNamespaceOptions = {
+ workspaceId: string;
+ limit: number;
+ cursor?: { id: string } | undefined;
+};
+
+export async function fetchRatelimitNamespaces({
+ workspaceId,
+ limit,
+ cursor,
+}: RatelimitNamespaceOptions): Promise {
+ // Get the total count of namespaces
+ const totalResult = await db
+ .select({ count: sql`count(*)` })
+ .from(schema.ratelimitNamespaces)
+ .where(
+ and(
+ eq(schema.ratelimitNamespaces.workspaceId, workspaceId),
+ isNull(schema.ratelimitNamespaces.deletedAtM),
+ ),
+ );
+
+ const total = Number(totalResult[0]?.count || 0);
+
+ // Query for ratelimit namespaces
+ const query = db.query.ratelimitNamespaces.findMany({
+ where: (table, { and, eq, isNull, gt }) => {
+ const conditions = [eq(table.workspaceId, workspaceId), isNull(table.deletedAtM)];
+ if (cursor) {
+ conditions.push(gt(table.id, cursor.id));
+ }
+ return and(...conditions);
+ },
+ columns: {
+ id: true,
+ name: true,
+ },
+ orderBy: (table, { asc }) => [asc(table.id)],
+ limit: limit + 1, // Fetch one extra to determine if there are more
+ });
+
+ const namespaces = await query;
+ const hasMore = namespaces.length > limit;
+ const namespaceItems = hasMore ? namespaces.slice(0, limit) : namespaces;
+ const nextCursor =
+ hasMore && namespaceItems.length > 0
+ ? { id: namespaceItems[namespaceItems.length - 1].id }
+ : undefined;
+
+ return {
+ namespaceList: namespaceItems,
+ hasMore,
+ nextCursor,
+ total,
+ };
+}
diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/query-keys/schemas.ts b/apps/dashboard/lib/trpc/routers/ratelimit/query-keys/schemas.ts
new file mode 100644
index 0000000000..b341023e64
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/ratelimit/query-keys/schemas.ts
@@ -0,0 +1,26 @@
+import { z } from "zod";
+
+export const ratelimitNamespace = z.object({
+ id: z.string(),
+ name: z.string(),
+});
+
+export type RatelimitNamespace = z.infer;
+
+const Cursor = z.object({
+ id: z.string(),
+});
+
+export const ratelimitNamespacesResponse = z.object({
+ namespaceList: z.array(ratelimitNamespace),
+ hasMore: z.boolean(),
+ nextCursor: Cursor.optional(),
+ total: z.number(),
+});
+
+export type RatelimitNamespacesResponse = z.infer;
+
+export const queryRatelimitNamespacesPayload = z.object({
+ limit: z.number().min(1).max(18).default(9),
+ cursor: Cursor.optional(),
+});
diff --git a/internal/icons/src/icons/arrow-dotted-rotate-anticlockwise.tsx b/internal/icons/src/icons/arrow-dotted-rotate-anticlockwise.tsx
new file mode 100644
index 0000000000..807be0f09d
--- /dev/null
+++ b/internal/icons/src/icons/arrow-dotted-rotate-anticlockwise.tsx
@@ -0,0 +1,57 @@
+/**
+ * Copyright © Nucleo
+ * Version 1.3, January 3, 2024
+ * Nucleo Icons
+ * https://nucleoapp.com/
+ * - Redistribution of icons is prohibited.
+ * - Icons are restricted for use only within the product they are bundled with.
+ *
+ * For more details:
+ * https://nucleoapp.com/license
+ */
+import type React from "react";
+import { type IconProps, sizeMap } from "../props";
+
+export const ArrowDottedRotateAnticlockwise: React.FC = ({
+ size = "xl-thin",
+ ...props
+}) => {
+ const { size: pixelSize } = sizeMap[size];
+
+ return (
+
+ );
+};
diff --git a/internal/icons/src/icons/arrow-opposite-direction-y.tsx b/internal/icons/src/icons/arrow-opposite-direction-y.tsx
new file mode 100644
index 0000000000..2162dba7b3
--- /dev/null
+++ b/internal/icons/src/icons/arrow-opposite-direction-y.tsx
@@ -0,0 +1,68 @@
+/**
+ * Copyright © Nucleo
+ * Version 1.3, January 3, 2024
+ * Nucleo Icons
+ * https://nucleoapp.com/
+ * - Redistribution of icons is prohibited.
+ * - Icons are restricted for use only within the product they are bundled with.
+ *
+ * For more details:
+ * https://nucleoapp.com/license
+ */
+import type React from "react";
+import { type IconProps, sizeMap } from "../props";
+
+export const ArrowOppositeDirectionY: React.FC = ({ size = "xl-thin", ...props }) => {
+ const { size: pixelSize, strokeWidth } = sizeMap[size];
+
+ return (
+
+ );
+};
diff --git a/internal/icons/src/icons/caret-right.tsx b/internal/icons/src/icons/caret-right.tsx
index 1c7ebc4657..bb508cd72d 100644
--- a/internal/icons/src/icons/caret-right.tsx
+++ b/internal/icons/src/icons/caret-right.tsx
@@ -10,11 +10,18 @@
* https://nucleoapp.com/license
*/
import type React from "react";
-import type { IconProps } from "../props";
+import { type IconProps, sizeMap } from "../props";
-export const CaretRight: React.FC = (props) => {
+export const CaretRight: React.FC = ({ size = "xl-thin", ...props }) => {
+ const { size: pixelSize, strokeWidth } = sizeMap[size];
return (
-