Files
bifrost/ui/components/sidebar.tsx
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

1272 lines
41 KiB
TypeScript

import {
ArrowUpRight,
BookUser,
Boxes,
BoxIcon,
BugIcon,
Building,
Building2,
ChartColumnBig,
ChevronsLeftRightEllipsis,
Construction,
DatabaseZap,
FlaskConical,
FolderGit,
Globe,
KeyRound,
Landmark,
LayoutGrid,
LogOut,
Logs,
Network,
PanelLeftClose,
Plug,
Puzzle,
ScrollText,
Search,
SearchCheck,
Settings,
Settings2Icon,
ShieldCheck,
ShieldUser,
Shuffle,
SlidersHorizontal,
Telescope,
ToolCase,
TrendingUp,
User,
UserRoundCheck,
Users,
Wallet,
WalletCards,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar";
import { useWebSocket } from "@/hooks/useWebSocket";
import { IS_ENTERPRISE, TRIAL_EXPIRY } from "@/lib/constants/config";
import { useGetCoreConfigQuery, useGetLatestReleaseQuery, useGetVersionQuery, useLogoutMutation } from "@/lib/store";
import { cn } from "@/lib/utils";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import type { UserInfo } from "@enterprise/lib/store/utils/tokenManager";
import { getUserInfo } from "@enterprise/lib/store/utils/tokenManager";
import { BooksIcon, DiscordLogoIcon, GithubLogoIcon } from "@phosphor-icons/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { differenceInDays } from "date-fns";
import { ChevronRight } from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCookies } from "react-cookie";
import { ThemeToggle } from "./themeToggle";
import { Badge } from "./ui/badge";
import { PromoCardStack } from "./ui/promoCardStack";
// Cookie name for dismissing production setup card
const PRODUCTION_SETUP_DISMISSED_COOKIE = "bifrost_production_setup_dismissed";
// Custom MCP Icon Component
const MCPIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="currentColor"
fillRule="evenodd"
height="1em"
style={{ flex: "none", lineHeight: 1 }}
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
aria-label="MCP clients icon"
>
<title>MCP clients icon</title>
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
</svg>
);
// Main navigation items
// External links
const externalLinks = [
{
title: "Discord Server",
url: "https://discord.gg/exN5KAydbU",
icon: DiscordLogoIcon,
},
{
title: "GitHub Repository",
url: "https://github.com/maximhq/bifrost",
icon: GithubLogoIcon,
},
{
title: "Report a bug",
url: "https://github.com/maximhq/bifrost/issues/new?title=[Bug Report]&labels=bug&type=bug&projects=maximhq/1",
icon: BugIcon,
strokeWidth: 1.5,
},
{
title: "Full Documentation",
url: "https://docs.getbifrost.ai",
icon: BooksIcon,
strokeWidth: 1,
},
];
// Base promotional card (memoized outside component to prevent recreation)
const productionSetupHelpCard = {
id: "production-setup",
title: "Need help with production setup?",
description: (
<>
We offer help with production setup including custom integrations and dedicated support.
<br />
<br />
Book a demo with our team{" "}
<a
href="https://calendly.com/maximai/bifrost-demo?utm_source=bfd_sdbr"
target="_blank"
className="text-primary font-medium underline"
rel="noopener noreferrer"
>
here
</a>
.
</>
),
dismissible: true,
};
// Sidebar item interface
interface SidebarItem {
title: string;
url: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
isAllowed?: boolean;
hasAccess: boolean;
subItems?: SidebarItem[];
tag?: string;
isExternal?: boolean;
queryParam?: string; // Optional: for tab-based subitems (e.g., "client-settings")
}
const getSidebarItemHref = (item: Pick<SidebarItem, "url" | "queryParam">) => {
return item.queryParam ? `${item.url}?tab=${item.queryParam}` : item.url;
};
const TIME_FILTER_PAGES = new Set(["/workspace/dashboard", "/workspace/logs", "/workspace/mcp-logs"]);
const SidebarItemView = ({
item,
isActive,
isExternal,
isWebSocketConnected,
isExpanded,
onToggle,
pathname,
search,
isSidebarCollapsed,
expandSidebar,
highlightedUrl,
}: {
item: SidebarItem;
isActive: boolean;
isExternal?: boolean;
isWebSocketConnected: boolean;
isExpanded?: boolean;
onToggle?: () => void;
pathname: string;
search: string;
isSidebarCollapsed: boolean;
expandSidebar: () => void;
highlightedUrl?: string;
}) => {
const hasSubItems = "subItems" in item && item.subItems && item.subItems.length > 0;
const isRouteMatch = (url: string) => {
if (url === "/workspace/custom-pricing") return pathname === url;
return pathname.startsWith(url);
};
const isAnySubItemActive =
hasSubItems &&
item.subItems?.some((subItem) => {
return isRouteMatch(subItem.url);
});
const handleClick = (e: React.MouseEvent) => {
if (hasSubItems && item.hasAccess) {
e.preventDefault();
// If sidebar is collapsed, expand it first then toggle the submenu
if (isSidebarCollapsed) {
expandSidebar();
// Small delay to allow sidebar to expand before toggling submenu
setTimeout(() => {
if (onToggle) onToggle();
}, 100);
} else if (onToggle) {
onToggle();
}
}
};
const isHighlighted = !hasSubItems && highlightedUrl === item.url;
const buttonClassName = `relative h-7.5 cursor-pointer rounded-sm border px-3 transition-all duration-200 ${
isHighlighted
? "bg-sidebar-accent text-accent-foreground border-primary/20"
: isActive || isAnySubItemActive
? "bg-sidebar-accent text-primary border-primary/20"
: item.hasAccess
? "hover:bg-sidebar-accent hover:text-accent-foreground border-transparent text-slate-500 dark:text-zinc-400"
: "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
} `;
const innerContent = (
<div className="flex w-full items-center justify-between">
<div className="flex w-full items-center gap-2">
<item.icon className={`h-4 w-4 shrink-0 ${isActive || isAnySubItemActive ? "text-primary" : "text-muted-foreground"}`} />
<span className={`text-sm group-data-[collapsible=icon]:hidden ${isActive || isAnySubItemActive ? "font-medium" : "font-normal"}`}>
{item.title}
</span>
{item.tag && (
<Badge variant="secondary" className="text-muted-foreground ml-auto text-xs group-data-[collapsible=icon]:hidden">
{item.tag}
</Badge>
)}
</div>
{hasSubItems && (
<ChevronRight
className={`h-4 w-4 transition-transform duration-200 group-data-[collapsible=icon]:hidden ${isExpanded ? "rotate-90" : ""}`}
/>
)}
{!hasSubItems && item.url === "/logs" && isWebSocketConnected && (
<div className="h-2 w-2 animate-pulse rounded-full bg-green-800 dark:bg-green-200" />
)}
{isExternal && <ArrowUpRight className="text-muted-foreground h-4 w-4 group-data-[collapsible=icon]:hidden" size={16} />}
</div>
);
// Render strategy:
// - Items with sub-items: <button> (toggle, not navigation)
// - Leaf items, no access: <button> (disabled-style, non-clickable)
// - Leaf items, external: <a target="_blank">
// - Leaf items, internal: TanStack <Link> with preload-on-hover
let menuButton: React.ReactNode;
if (hasSubItems) {
menuButton = (
<SidebarMenuButton tooltip={item.title} className={buttonClassName} onClick={handleClick}>
{innerContent}
</SidebarMenuButton>
);
} else if (!item.hasAccess) {
menuButton = (
<SidebarMenuButton tooltip={item.title} data-nav-url={item.url} className={buttonClassName}>
{innerContent}
</SidebarMenuButton>
);
} else if (isExternal) {
menuButton = (
<SidebarMenuButton asChild tooltip={item.title} className={buttonClassName}>
<a href={item.url} target="_blank" rel="noopener noreferrer" data-nav-url={item.url}>
{innerContent}
</a>
</SidebarMenuButton>
);
} else {
menuButton = (
<SidebarMenuButton asChild tooltip={item.title} className={buttonClassName}>
<Link to={item.url as any} preload="intent" data-nav-url={item.url}>
{innerContent}
</Link>
</SidebarMenuButton>
);
}
return (
<SidebarMenuItem key={item.title}>
{menuButton}
{hasSubItems && isExpanded && (
<SidebarMenuSub className="border-sidebar-border mt-1 ml-4 space-y-0.5 border-l pl-2">
{item.subItems?.map((subItem: SidebarItem) => {
const baseHref = getSidebarItemHref(subItem);
const subItemHref = (() => {
if (TIME_FILTER_PAGES.has(subItem.url) && TIME_FILTER_PAGES.has(pathname)) {
const currentParams = new URLSearchParams(search);
const startTime = currentParams.get("start_time");
const endTime = currentParams.get("end_time");
const period = currentParams.get("period");
if ((startTime && endTime) || period) {
const params = new URLSearchParams();
if (startTime) params.set("start_time", startTime);
if (endTime) params.set("end_time", endTime);
if (period) params.set("period", period);
const sep = baseHref.includes("?") ? "&" : "?";
return `${baseHref}${sep}${params.toString()}`;
}
}
return baseHref;
})();
// For query param based subitems, check if tab matches
const isSubItemActive = subItem.queryParam ? pathname === subItem.url : isRouteMatch(subItem.url);
const isSubItemHighlighted = highlightedUrl ? subItemHref.startsWith(highlightedUrl) : false;
const SubItemIcon = subItem.icon;
const subItemClassName = `h-7 cursor-pointer rounded-sm px-2 transition-all duration-200 ${
isSubItemHighlighted
? "bg-sidebar-accent text-accent-foreground"
: isSubItemActive
? "bg-sidebar-accent text-primary font-medium"
: subItem.hasAccess === false
? "hover:bg-destructive/5 hover:text-muted-foreground text-muted-foreground cursor-not-allowed border-transparent"
: "hover:bg-sidebar-accent hover:text-accent-foreground text-slate-500 dark:text-zinc-400"
}`;
const subInner = (
<div className="flex w-full items-center gap-2">
{SubItemIcon && <SubItemIcon className={`h-3.5 w-3.5 ${isSubItemActive ? "text-primary" : "text-muted-foreground"}`} />}
<span className={`text-sm ${isSubItemActive ? "font-medium" : "font-normal"}`}>{subItem.title}</span>
{subItem.tag && (
<Badge variant="secondary" className="text-muted-foreground ml-auto text-xs">
{subItem.tag}
</Badge>
)}
</div>
);
return (
<SidebarMenuSubItem key={subItem.title}>
{subItem.hasAccess === false ? (
<SidebarMenuSubButton data-nav-url={subItemHref} className={subItemClassName}>
{subInner}
</SidebarMenuSubButton>
) : (
<SidebarMenuSubButton asChild className={subItemClassName}>
<Link to={subItemHref as any} preload="intent" data-nav-url={subItemHref}>
{subInner}
</Link>
</SidebarMenuSubButton>
)}
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
};
// Helper function to compare semantic versions
const compareVersions = (v1: string, v2: string): number => {
// Remove 'v' prefix if present
const cleanV1 = v1.startsWith("v") ? v1.slice(1) : v1;
const cleanV2 = v2.startsWith("v") ? v2.slice(1) : v2;
// Split into main version and prerelease
const [mainV1, prereleaseV1] = cleanV1.split("-");
const [mainV2, prereleaseV2] = cleanV2.split("-");
// Compare main version numbers (major.minor.patch)
const partsV1 = mainV1.split(".").map(Number);
const partsV2 = mainV2.split(".").map(Number);
for (let i = 0; i < Math.max(partsV1.length, partsV2.length); i++) {
const num1 = partsV1[i] || 0;
const num2 = partsV2[i] || 0;
if (num1 > num2) return 1;
if (num1 < num2) return -1;
}
// If main versions are equal, check prerelease
// Version without prerelease is higher than version with prerelease
if (!prereleaseV1 && prereleaseV2) return 1;
if (prereleaseV1 && !prereleaseV2) return -1;
// Both have prereleases, compare them
if (prereleaseV1 && prereleaseV2) {
// Extract prerelease number (e.g., "prerelease1" -> 1)
const prereleaseNum1 = parseInt(prereleaseV1.replace(/\D/g, "")) || 0;
const prereleaseNum2 = parseInt(prereleaseV2.replace(/\D/g, "")) || 0;
if (prereleaseNum1 > prereleaseNum2) return 1;
if (prereleaseNum1 < prereleaseNum2) return -1;
}
return 0;
};
export default function AppSidebar() {
const pathname = useLocation({ select: (l) => l.pathname });
const search = useLocation({ select: (l) => l.searchStr ?? "" });
const tsNavigate = useNavigate();
// Wrapper that accepts arbitrary string URLs (TanStack Router's `to` is
// strictly typed, but our sidebar items come from a runtime config).
const navigate = useCallback((url: string) => tsNavigate({ to: url as any }), [tsNavigate]);
const [mounted, setMounted] = useState(false);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [areCardsEmpty, setAreCardsEmpty] = useState(false);
const [userPopoverOpen, setUserPopoverOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [focusedIndex, setFocusedIndex] = useState(-1);
const searchInputRef = useRef<HTMLInputElement>(null);
const [cookies, setCookie] = useCookies([PRODUCTION_SETUP_DISMISSED_COOKIE]);
const isProductionSetupDismissed = !!cookies[PRODUCTION_SETUP_DISMISSED_COOKIE];
const { data: latestRelease } = useGetLatestReleaseQuery(undefined, {
skip: !mounted, // Only fetch after component is mounted
});
const hasLogsAccess = useRbac(RbacResource.Logs, RbacOperation.View);
const hasObservabilityAccess = useRbac(RbacResource.Observability, RbacOperation.View);
const hasModelProvidersAccess = useRbac(RbacResource.ModelProvider, RbacOperation.View);
const hasMCPGatewayAccess = useRbac(RbacResource.MCPGateway, RbacOperation.View);
const hasPluginsAccess = useRbac(RbacResource.Plugins, RbacOperation.View);
const hasUsersAccess = useRbac(RbacResource.Users, RbacOperation.View);
const hasUserProvisioningAccess = useRbac(RbacResource.UserProvisioning, RbacOperation.View);
const hasAuditLogsAccess = useRbac(RbacResource.AuditLogs, RbacOperation.View);
const hasCustomersAccess = useRbac(RbacResource.Customers, RbacOperation.View);
const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View);
const hasBusinessUnitsAccess = useRbac(RbacResource.Governance, RbacOperation.View);
const hasRbacAccess = useRbac(RbacResource.RBAC, RbacOperation.View);
const hasVirtualKeysAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.View);
const hasGovernanceAccess = useRbac(RbacResource.Governance, RbacOperation.View);
const hasRoutingRulesAccess = useRbac(RbacResource.RoutingRules, RbacOperation.View);
const hasGuardrailsProvidersAccess = useRbac(RbacResource.GuardrailsProviders, RbacOperation.View);
const hasGuardrailsConfigAccess = useRbac(RbacResource.GuardrailsConfig, RbacOperation.View);
const hasClusterConfigAccess = useRbac(RbacResource.Cluster, RbacOperation.View);
const isAdaptiveRoutingAllowed = useRbac(RbacResource.AdaptiveRouter, RbacOperation.View);
const hasSettingsAccess = useRbac(RbacResource.Settings, RbacOperation.View);
const hasPromptRepositoryAccess = useRbac(RbacResource.PromptRepository, RbacOperation.View);
const hasPromptDeploymentStrategyAccess = useRbac(RbacResource.PromptDeploymentStrategy, RbacOperation.View);
const hasAccessProfilesAccess = useRbac(RbacResource.AccessProfiles, RbacOperation.View);
const { data: coreConfig } = useGetCoreConfigQuery({});
const isDbConnected = coreConfig?.is_db_connected ?? false;
const items = useMemo(
() => [
{
title: "Observability",
url: "/workspace/logs",
icon: Telescope,
description: "Request logs & monitoring",
hasAccess: hasLogsAccess,
subItems: [
{
title: "Dashboard",
url: "/workspace/dashboard",
icon: ChartColumnBig,
description: "Dashboard",
hasAccess: hasObservabilityAccess,
},
{
title: "LLM Logs",
url: "/workspace/logs",
icon: Logs,
description: "LLM request logs & monitoring",
hasAccess: hasLogsAccess,
},
{
title: "MCP Logs",
url: "/workspace/mcp-logs",
icon: MCPIcon,
description: "MCP tool execution logs",
hasAccess: hasLogsAccess,
},
{
title: "Connectors",
url: "/workspace/observability",
icon: ChevronsLeftRightEllipsis,
description: "Log connectors",
hasAccess: hasObservabilityAccess,
},
{
title: "Logs Settings",
url: "/workspace/config/logging",
icon: Settings,
description: "Logs configuration",
hasAccess: hasSettingsAccess,
},
],
},
{
title: "Models",
url: "/workspace/providers",
icon: BoxIcon,
description: "Configure models",
hasAccess: true,
subItems: [
{
title: "Model Catalog",
url: "/workspace/model-catalog",
icon: LayoutGrid,
description: "Overview of providers, keys, and usage",
hasAccess: hasModelProvidersAccess,
},
{
title: "Model Providers",
url: "/workspace/providers",
icon: Boxes,
description: "Configure models",
hasAccess: hasModelProvidersAccess,
},
{
title: "Budgets & Limits",
url: "/workspace/model-limits",
icon: Wallet,
description: "Model limits",
hasAccess: hasGovernanceAccess,
},
{
title: "Routing Rules",
url: "/workspace/routing-rules",
icon: Network,
description: "Intelligent routing rules",
hasAccess: hasRoutingRulesAccess,
},
{
title: "Pricing Overrides",
url: "/workspace/custom-pricing/overrides",
icon: SlidersHorizontal,
description: "Scoped pricing overrides",
hasAccess: hasSettingsAccess,
},
{
title: "Model Settings",
url: "/workspace/custom-pricing",
icon: Settings,
description: "Model and routing configuration",
hasAccess: hasSettingsAccess,
},
],
},
{
title: "MCP Gateway",
icon: MCPIcon,
description: "MCP configuration",
url: "/workspace/mcp-gateway",
hasAccess: hasMCPGatewayAccess,
subItems: [
{
title: "MCP Catalog",
url: "/workspace/mcp-registry",
icon: LayoutGrid,
description: "MCP tool catalog",
hasAccess: hasMCPGatewayAccess,
},
{
title: "Tool Groups",
url: "/workspace/mcp-tool-groups",
icon: ToolCase,
description: "Tool Groups",
hasAccess: hasMCPGatewayAccess,
},
{
title: "Auth Config",
url: "/workspace/mcp-auth-config",
icon: ShieldUser,
description: "MCP auth config",
hasAccess: hasMCPGatewayAccess,
},
{
title: "MCP Settings",
url: "/workspace/mcp-settings",
icon: Settings,
description: "MCP configuration",
hasAccess: hasMCPGatewayAccess,
},
],
},
{
title: "Plugins",
url: "/workspace/plugins",
icon: Puzzle,
description: "Manage custom plugins",
hasAccess: hasPluginsAccess,
},
{
title: "Governance",
url: "/workspace/governance",
icon: Landmark,
description: "Virtual keys, users, teams, customers & roles",
hasAccess: hasGovernanceAccess,
subItems: [
{
title: "Virtual Keys",
url: "/workspace/governance/virtual-keys",
icon: KeyRound,
description: "Manage virtual keys & access",
hasAccess: hasVirtualKeysAccess,
},
{
title: "Users",
url: "/workspace/governance/users",
icon: Users,
description: "Manage users",
hasAccess: hasUsersAccess,
},
{
title: "Teams",
url: "/workspace/governance/teams",
icon: Building,
description: "Manage teams",
hasAccess: hasTeamsAccess,
},
{
title: "Business Units",
url: "/workspace/governance/business-units",
icon: Building2,
description: "Manage business units",
hasAccess: hasBusinessUnitsAccess,
},
{
title: "Customers",
url: "/workspace/governance/customers",
icon: WalletCards,
description: "Manage customers",
hasAccess: hasCustomersAccess,
},
{
title: "User Provisioning",
url: "/workspace/scim",
icon: BookUser,
description: "User management and provisioning",
hasAccess: hasUserProvisioningAccess,
},
{
title: "Roles & Permissions",
url: "/workspace/governance/rbac",
icon: UserRoundCheck,
description: "User roles and permissions",
hasAccess: hasRbacAccess,
},
{
title: "Access Profiles",
url: "/workspace/governance/access-profiles",
icon: ShieldCheck,
description: "Manage access profiles for roles",
hasAccess: hasAccessProfilesAccess,
},
{
title: "Audit Logs",
url: "/workspace/audit-logs",
icon: ScrollText,
description: "Audit logs and compliance",
hasAccess: hasAuditLogsAccess,
},
],
},
{
title: "Guardrails",
url: "/workspace/guardrails",
icon: Construction,
description: "Guardrails configuration",
hasAccess: hasGuardrailsConfigAccess || hasGuardrailsProvidersAccess,
subItems: [
{
title: "Rules",
url: "/workspace/guardrails/configuration",
icon: SearchCheck,
description: "Guardrail rules",
hasAccess: hasGuardrailsConfigAccess,
},
{
title: "Providers",
url: "/workspace/guardrails/providers",
icon: Boxes,
description: "Guardrail providers configuration",
hasAccess: hasGuardrailsProvidersAccess,
},
],
},
{
title: "Cluster Config",
url: "/workspace/cluster",
icon: Network,
description: "Manage Bifrost cluster",
hasAccess: hasClusterConfigAccess,
},
{
title: "Adaptive Routing",
url: "/workspace/adaptive-routing",
icon: Shuffle,
description: "Manage adaptive load balancer",
hasAccess: isAdaptiveRoutingAllowed,
},
...(isDbConnected
? [
{
title: "Prompt Repository",
url: "/workspace/prompt-repo",
icon: FolderGit,
description: "Prompt repository",
hasAccess: hasPromptRepositoryAccess,
},
]
: []),
{
title: "Evals",
url: "https://www.getmaxim.ai",
icon: FlaskConical,
isExternal: true,
description: "Evaluations",
hasAccess: true,
},
{
title: "Settings",
url: "/workspace/config",
icon: Settings2Icon,
description: "Bifrost settings",
hasAccess: hasSettingsAccess || hasAuditLogsAccess || hasUserProvisioningAccess,
subItems: [
{
title: "Client Settings",
url: "/workspace/config/client-settings",
icon: Settings,
description: "Client configuration settings",
hasAccess: hasSettingsAccess,
},
{
title: "Compatibility",
url: "/workspace/config/compatibility",
icon: Plug,
description: "Compatibility conversion settings",
hasAccess: hasSettingsAccess,
},
{
title: "Caching",
url: "/workspace/config/caching",
icon: DatabaseZap,
description: "Caching configuration",
hasAccess: hasSettingsAccess,
},
{
title: "Security",
url: "/workspace/config/security",
icon: ShieldCheck,
description: "Security settings",
hasAccess: hasSettingsAccess,
},
...(IS_ENTERPRISE
? [
{
title: "Proxy",
url: "/workspace/config/proxy",
icon: Globe,
description: "Proxy configuration",
hasAccess: hasSettingsAccess,
},
]
: []),
{
title: "API Keys",
url: "/workspace/config/api-keys",
icon: KeyRound,
description: "API keys management",
hasAccess: hasSettingsAccess,
},
{
title: "Performance Tuning",
url: "/workspace/config/performance-tuning",
icon: TrendingUp,
description: "Performance tuning settings",
hasAccess: hasSettingsAccess,
},
],
},
],
[
hasLogsAccess,
hasObservabilityAccess,
hasModelProvidersAccess,
hasMCPGatewayAccess,
hasPluginsAccess,
hasUsersAccess,
hasUserProvisioningAccess,
hasAuditLogsAccess,
hasCustomersAccess,
hasTeamsAccess,
hasBusinessUnitsAccess,
hasRbacAccess,
hasVirtualKeysAccess,
hasGovernanceAccess,
hasRoutingRulesAccess,
hasGuardrailsProvidersAccess,
hasGuardrailsConfigAccess,
hasClusterConfigAccess,
isAdaptiveRoutingAllowed,
hasSettingsAccess,
hasPromptRepositoryAccess,
hasPromptDeploymentStrategyAccess,
hasAccessProfilesAccess,
isDbConnected,
],
);
const filteredItems: SidebarItem[] = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return items;
return items
.map((item) => {
const parentMatches = item.title.toLowerCase().includes(query);
if (parentMatches) return item;
if (item.subItems) {
const matchingSubItems = item.subItems.filter((sub) => sub.title.toLowerCase().includes(query));
if (matchingSubItems.length > 0) {
return { ...item, subItems: matchingSubItems };
}
}
return null;
})
.filter(Boolean) as SidebarItem[];
}, [items, searchQuery]);
const { data: version } = useGetVersionQuery();
const { resolvedTheme } = useTheme();
const [logout] = useLogoutMutation();
// Get user info from localStorage (for enterprise SCIM OAuth)
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
useEffect(() => {
if (IS_ENTERPRISE) {
const info = getUserInfo();
setUserInfo(info);
}
}, []);
const showNewReleaseBanner = useMemo(() => {
if (IS_ENTERPRISE) return false;
if (latestRelease && version) {
return compareVersions(latestRelease.name, version) > 0;
}
return false;
}, [latestRelease, version]);
const isAuthEnabled = coreConfig?.auth_config?.is_enabled || false;
useEffect(() => {
setMounted(true);
}, []);
// Auto-expand items when their subitems are active
useEffect(() => {
const newExpandedItems = new Set<string>();
const isRouteMatch = (url: string) => {
if (url === "/workspace/custom-pricing") return pathname === url;
return pathname.startsWith(url);
};
items.forEach((item) => {
if (item.subItems?.some((subItem) => isRouteMatch(subItem.url))) {
newExpandedItems.add(item.title);
}
});
if (newExpandedItems.size > 0) {
setExpandedItems((prev) => new Set([...prev, ...newExpandedItems]));
}
}, [pathname, items]);
// Auto-expand parents when search matches their subItems
useEffect(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return;
const toExpand = new Set<string>();
items.forEach((item) => {
if (!item.subItems?.length) return;
const parentMatches = item.title.toLowerCase().includes(query);
if (parentMatches) return;
const hasMatchingChild = item.subItems.some((sub) => sub.title.toLowerCase().includes(query));
if (hasMatchingChild) {
toExpand.add(item.title);
}
});
if (toExpand.size > 0) {
setExpandedItems((prev) => {
const hasAll = [...toExpand].every((t) => prev.has(t));
if (hasAll) return prev;
return new Set([...prev, ...toExpand]);
});
}
}, [searchQuery, items]);
// Cmd+K to focus search input
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
// Flat list of navigable items for keyboard navigation
const navigableItems = useMemo(() => {
const result: {
title: string;
url: string;
queryParam?: string;
isExternal?: boolean;
}[] = [];
for (const item of filteredItems) {
if (item.isExternal) {
if (item.hasAccess) result.push({ title: item.title, url: item.url, isExternal: true });
continue;
}
const hasSubItems = item.subItems && item.subItems.length > 0;
if (hasSubItems) {
// When search is active or parent is expanded, include visible subItems
if (searchQuery.trim() || expandedItems.has(item.title)) {
for (const sub of item.subItems!) {
if (sub.hasAccess === false) continue;
result.push({
title: sub.title,
url: getSidebarItemHref(sub),
queryParam: sub.queryParam,
});
}
} else {
// Parent is collapsed - include parent as a toggle target
if (item.hasAccess) result.push({ title: item.title, url: item.url });
}
} else {
if (item.hasAccess) result.push({ title: item.title, url: item.url });
}
}
return result;
}, [filteredItems, expandedItems, searchQuery]);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setFocusedIndex((prev) => Math.min(prev + 1, navigableItems.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const target = navigableItems[focusedIndex];
if (target) {
const url = target.url;
if (target.isExternal || e.metaKey || e.ctrlKey) {
window.open(url, "_blank", "noopener,noreferrer");
} else {
navigate(url);
}
setSearchQuery("");
setFocusedIndex(-1);
searchInputRef.current?.blur();
}
} else if (e.key === "Escape") {
setSearchQuery("");
setFocusedIndex(-1);
searchInputRef.current?.blur();
}
},
[navigableItems, focusedIndex, navigate],
);
// Auto-scroll focused item into view
useEffect(() => {
if (focusedIndex < 0) return;
const url = navigableItems[focusedIndex]?.url;
if (!url) return;
const el = document.querySelector(`[data-nav-url="${url}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [focusedIndex, navigableItems]);
const toggleItem = (title: string) => {
setExpandedItems((prev) => {
const next = new Set(prev);
if (next.has(title)) {
next.delete(title);
} else {
next.add(title);
}
return next;
});
};
const configExceptions = ["/workspace/config/logging"];
const isActiveRoute = (url: string) => {
if (url === "/" && pathname === "/") return true;
// Avoid double-highlighting with "/workspace/custom-pricing/overrides"
if (url === "/workspace/custom-pricing") return pathname === url;
if (url !== "/" && pathname.startsWith(url)) {
if (url === "/workspace/config" && configExceptions.some((e) => pathname.startsWith(e))) {
return false;
}
return true;
}
return false;
};
// Always render the light theme version for SSR to avoid hydration mismatch
const logoSrc = mounted && resolvedTheme === "dark" ? "/bifrost-logo-dark.webp" : "/bifrost-logo.webp";
const iconSrc = mounted && resolvedTheme === "dark" ? "/bifrost-icon-dark.webp" : "/bifrost-icon.webp";
const { isConnected: isWebSocketConnected } = useWebSocket();
// New release image - based on theme
const newReleaseImage = mounted && resolvedTheme === "dark" ? "/images/new-release-image-dark.webp" : "/images/new-release-image.webp";
// Memoize promo cards array to prevent duplicates and unnecessary re-renders
const promoCards = useMemo(() => {
const cards = [];
// Restart required card - non-dismissible, shown first
if (coreConfig?.restart_required?.required) {
cards.push({
id: "restart-required",
title: "Restart Required",
description: (
<div className="text-xs text-amber-700 dark:text-amber-300/80">
{coreConfig.restart_required.reason || "Configuration changes require a server restart to take effect."}
</div>
),
dismissible: false,
variant: "warning" as const,
});
}
if (showNewReleaseBanner && latestRelease) {
cards.push({
id: "new-release",
title: `${latestRelease.name} is now available.`,
description: (
<div className="flex h-full flex-col gap-2">
<img src={newReleaseImage} alt="Bifrost" className="h-[95px] rounded-md object-cover" />
<a
href={`https://docs.getbifrost.ai/changelogs/${latestRelease.name}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-auto pb-1 font-medium underline"
>
View release notes
</a>
</div>
),
dismissible: true,
});
}
// Only show after mounted to ensure cookie is properly hydrated and avoid flash
if (!IS_ENTERPRISE && mounted && !isProductionSetupDismissed) {
cards.push(productionSetupHelpCard);
}
return cards;
}, [coreConfig?.restart_required, showNewReleaseBanner, latestRelease, newReleaseImage, isProductionSetupDismissed, mounted]);
// Reset areCardsEmpty when promoCards changes
useEffect(() => {
if (promoCards.length > 0) {
setAreCardsEmpty(false);
}
}, [promoCards]);
const hasPromoCards = promoCards.length > 0 && !areCardsEmpty;
// When cards are present: 13rem (header 3rem + bottom section ~10rem)
// When no cards: 8rem (header 3rem + bottom section without cards ~5rem)
const sidebarGroupHeight = hasPromoCards ? "h-[calc(100vh-13rem)]" : "h-[calc(100vh-8rem)]";
const handleCardsEmpty = () => {
setAreCardsEmpty(true);
};
const handlePromoDismiss = useCallback(
(cardId: string) => {
if (cardId === "production-setup") {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 7);
setCookie(PRODUCTION_SETUP_DISMISSED_COOKIE, "true", {
path: "/",
expires: expiryDate,
});
}
},
[setCookie],
);
const handleLogout = async () => {
try {
setUserPopoverOpen(false);
await logout().unwrap();
navigate("/login");
} catch {
// Even if logout fails on server, redirect to login
navigate("/login");
}
};
const trialDaysRemaining = useMemo(() => {
if (IS_ENTERPRISE && TRIAL_EXPIRY) {
const daysRemaining = differenceInDays(new Date(TRIAL_EXPIRY), new Date());
return daysRemaining > 0 ? daysRemaining : 0;
}
return null;
}, []);
const { state: sidebarState, toggleSidebar } = useSidebar();
return (
<Sidebar collapsible="icon" className="overflow-y-clip border-none bg-transparent">
<SidebarHeader className="mt-1 ml-2 flex justify-between px-0 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:h-auto">
{/* Expanded state: horizontal layout */}
<div className="flex h-10 w-full items-center justify-between px-1.5 group-data-[collapsible=icon]:hidden">
<Link to="/workspace/logs" className="group flex items-center gap-2 pl-2">
<img className="h-[22px] w-auto" src={logoSrc} alt="Bifrost" width={70} height={70} />
</Link>
<button
onClick={toggleSidebar}
className="text-muted-foreground hover:text-foreground hover:bg-sidebar-accent flex h-7 w-7 items-center justify-center rounded-md transition-colors"
aria-label="Collapse sidebar"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
{/* Collapsed state: vertical layout */}
<div
className="hidden w-full cursor-pointer flex-col items-center gap-2 py-2 group-data-[collapsible=icon]:flex"
onClick={toggleSidebar}
>
<img className="h-[22px] w-auto" src={iconSrc} alt="Bifrost" width={22} height={22} />
</div>
</SidebarHeader>
<div className="mx-2 pb-1 group-data-[collapsible=icon]:hidden">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<input
ref={searchInputRef}
type="text"
aria-label="Search sidebar navigation"
placeholder="Search..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setFocusedIndex(-1);
}}
onKeyDown={handleSearchKeyDown}
className="border-input text-foreground placeholder:text-shadow-muted-foreground focus:ring-ring h-8 w-full rounded-sm border bg-transparent pr-14 pl-8 text-sm outline-none focus:bg-transparent"
/>
<kbd className="text-muted-foreground pointer-events-none absolute top-1/2 right-2 flex -translate-y-1/2 gap-0.5 text-[10px]">
<span className="border-border bg-muted rounded-sm px-1 font-mono shadow-sm"></span>
<span className="border-border bg-muted rounded-sm px-1 font-mono shadow-sm">K</span>
</kbd>
</div>
</div>
<SidebarContent className="overflow-hidden pb-4">
<SidebarGroup className={`custom-scrollbar ${sidebarGroupHeight} overflow-scroll`}>
<SidebarGroupContent>
<SidebarMenu className="space-y-0.5">
{filteredItems.map((item) => {
const isActive = isActiveRoute(item.url);
const highlightedUrl = focusedIndex >= 0 ? navigableItems[focusedIndex]?.url : undefined;
return (
<SidebarItemView
key={item.title}
item={item}
isActive={isActive}
isExternal={item.isExternal ?? false}
isWebSocketConnected={isWebSocketConnected}
isExpanded={expandedItems.has(item.title)}
onToggle={() => toggleItem(item.title)}
pathname={pathname}
search={search}
isSidebarCollapsed={sidebarState === "collapsed"}
expandSidebar={() => toggleSidebar()}
highlightedUrl={highlightedUrl}
/>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<div className="flex flex-col gap-4 px-3 group-data-[collapsible=icon]:px-1">
<div className="mx-1 group-data-[collapsible=icon]:hidden">
<PromoCardStack cards={promoCards} onCardsEmpty={handleCardsEmpty} onDismiss={handlePromoDismiss} />
</div>
<div className="flex flex-row">
<div className="mx-auto flex flex-row gap-4 group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:gap-2">
{externalLinks.map((item, index) => (
<a
key={index}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="group flex w-full items-center justify-between"
title={item.title}
>
<div className="flex items-center space-x-3">
<item.icon
className="hover:text-primary text-muted-foreground h-5 w-5"
size={22}
weight="regular"
strokeWidth={item.strokeWidth}
/>
</div>
</a>
))}
<ThemeToggle />
{IS_ENTERPRISE && userInfo && (userInfo.name || userInfo.email) ? (
<Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
<PopoverTrigger asChild>
<button
className="hover:text-primary text-muted-foreground flex cursor-pointer items-center space-x-3 p-0.5"
type="button"
aria-label="User menu"
>
<User className="hover:text-primary text-muted-foreground h-4 w-4" size={20} strokeWidth={2} />
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-56 p-0">
<div className="flex flex-col">
<div className="px-4 py-3">
<p className="text-sm font-medium">{userInfo.name || userInfo.email || "User"}</p>
</div>
<Separator />
<button
onClick={handleLogout}
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm transition-colors"
type="button"
>
<LogOut className="h-4 w-4" strokeWidth={2} />
<span>Logout</span>
</button>
</div>
</PopoverContent>
</Popover>
) : isAuthEnabled && !IS_ENTERPRISE ? (
<div>
<button
className="hover:text-primary text-muted-foreground flex cursor-pointer items-center space-x-3 p-0.5"
onClick={handleLogout}
type="button"
aria-label="Logout"
>
<LogOut className="hover:text-primary text-muted-foreground h-4 w-4" size={20} strokeWidth={2} />
</button>
</div>
) : null}
</div>
</div>
<div className="mx-auto flex flex-col items-center gap-1 group-data-[collapsible=icon]:hidden">
<div className="font-mono text-xs">{version ?? ""}</div>
{trialDaysRemaining !== null && (
<div className={cn("text-xs", trialDaysRemaining < 3 ? "text-red-500" : "text-muted-foreground")}>
{trialDaysRemaining} {trialDaysRemaining === 1 ? "day" : "days"} remaining
</div>
)}
</div>
</div>
</SidebarContent>
</Sidebar>
);
}