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 ( + + + {SEGMENTS.map((id, index) => { + const distance = (SEGMENTS.length + index - segmentIndex) % SEGMENTS.length; + const opacity = distance <= 4 ? 1 - distance * 0.2 : 0.1; + 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 ( - + = (props) => { stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" - strokeWidth="2" + strokeWidth={strokeWidth} /> diff --git a/internal/icons/src/index.ts b/internal/icons/src/index.ts index de53742888..570183828b 100644 --- a/internal/icons/src/index.ts +++ b/internal/icons/src/index.ts @@ -68,3 +68,5 @@ export * from "./icons/triangle-warning"; export * from "./icons/ufo"; export * from "./icons/user-search"; export * from "./icons/xmark"; +export * from "./icons/arrow-opposite-direction-y"; +export * from "./icons/arrow-dotted-rotate-anticlockwise";