first commit
This commit is contained in:
18
ui/app/__error.tsx
Normal file
18
ui/app/__error.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ErrorComponent() {
|
||||
return (
|
||||
<main className="h-base flex items-center justify-center p-6">
|
||||
<div className="mx-auto w-full max-w-md text-center">
|
||||
<p className="text-foreground text-7xl font-bold tracking-tight">500</p>
|
||||
<h1 className="text-foreground mt-4 text-2xl font-semibold">Something went wrong</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">Something went wrong. Please refresh the page.</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<Button size={"sm"} data-testid="error-reload-btn" onClick={() => window.location.reload()}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
22
ui/app/__notFound.tsx
Normal file
22
ui/app/__notFound.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export function NotFoundComponent() {
|
||||
return (
|
||||
<main className="h-base flex items-center justify-center p-6">
|
||||
<div className="mx-auto w-full max-w-md text-center">
|
||||
<p className="text-foreground text-7xl font-bold tracking-tight">404</p>
|
||||
<h1 className="text-foreground mt-4 text-2xl font-semibold">Page not found</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">The page you are looking for doesn’t exist or has been moved</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<Link
|
||||
data-testid="not-found-go-home-link"
|
||||
to="/workspace/logs"
|
||||
className="bg-primary text-primary-foreground focus-visible:ring-primary inline-flex items-center rounded-md px-4 py-2 text-sm font-medium shadow transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
ui/app/__root.tsx
Normal file
23
ui/app/__root.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Outlet, createRootRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
/**
|
||||
* Root route. Intentionally minimal — does NOT wrap children in providers,
|
||||
* because the existing nested layouts (app/workspace/layout.tsx →
|
||||
* ClientLayout, app/login/layout.tsx, app/pprof/layout.tsx) each set up
|
||||
* their own ThemeProvider / ReduxProvider / NuqsAdapter / etc.
|
||||
*
|
||||
* If/when we consolidate provider setup, the providers can move here.
|
||||
*/
|
||||
export const Route = createRootRoute({
|
||||
beforeLoad: ({ location }) => {
|
||||
// Root index "/" → /login. The old app/page.tsx redirect lived here.
|
||||
if (location.pathname === "/" || location.pathname === "") {
|
||||
throw redirect({ to: "/login" });
|
||||
}
|
||||
},
|
||||
component: RootComponent,
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function AccessProfilesIndexView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<ShieldCheck className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock access profiles for better performance"
|
||||
description="This feature is a part of the Bifrost enterprise license. Create access profiles to control access to your resources."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/access-profiles"
|
||||
testIdPrefix="access-profiles"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Shuffle } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function AdaptiveRoutingView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<Shuffle className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock adaptive routing for better performance"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/adaptive-load-balancing"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Siren } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function AlertChannelsView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<Siren className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock alert channels for better observability"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/alert-channels"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useGetCoreConfigQuery } from "@/lib/store";
|
||||
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Copy, InfoIcon, KeyRound } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function APIKeysView() {
|
||||
const { data: bifrostConfig, isLoading } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const isAuthConfigure = useMemo(() => {
|
||||
return bifrostConfig?.auth_config?.is_enabled;
|
||||
}, [bifrostConfig]);
|
||||
|
||||
const curlExample = `# Base64 encode your username:password
|
||||
# Example: echo -n "username:password" | base64
|
||||
curl --location 'http://localhost:8080/v1/chat/completions'
|
||||
--header 'Content-Type: application/json'
|
||||
--header 'Accept: application/json'
|
||||
--header 'Authorization: Basic <base64_encoded_username:password>'
|
||||
--data '{
|
||||
"model": "openai/gpt-4",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "explain big bang?"
|
||||
}
|
||||
]
|
||||
}'`;
|
||||
|
||||
const { copy: copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
if (!isAuthConfigure) {
|
||||
return (
|
||||
<Alert variant="default">
|
||||
<InfoIcon className="text-muted h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="text-md text-muted-foreground">
|
||||
To generate API keys, you need to set up admin username and password first.{" "}
|
||||
<Link to="/workspace/config/security" className="text-md text-primary underline">
|
||||
Configure Security Settings
|
||||
</Link>
|
||||
.<br />
|
||||
<br />
|
||||
Once generated you will need to use this API key for all API calls to the Bifrost admin APIs and UI.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const isInferenceAuthDisabled = bifrostConfig?.auth_config?.disable_auth_on_inference ?? false;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<Alert variant="default">
|
||||
<InfoIcon className="text-muted h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="text-md text-muted-foreground">
|
||||
{isInferenceAuthDisabled ? (
|
||||
<>
|
||||
Authentication is currently <strong>disabled for inference API calls</strong>. You can make inference requests without
|
||||
authentication. Dashboard and admin API calls still require Basic auth with your admin credentials encoded in the standard{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-sm">username:password</code> format with base64 encoding.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Use Basic auth with your admin credentials when making API calls to Bifrost. Encode your credentials in the standard{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-sm">username:password</code> format with base64 encoding.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!isInferenceAuthDisabled && (
|
||||
<>
|
||||
<br />
|
||||
<p className="text-md text-muted-foreground">
|
||||
<strong>Example:</strong>
|
||||
</p>
|
||||
|
||||
<div className="relative mt-2 w-full min-w-0 overflow-x-auto">
|
||||
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(curlExample)} className="absolute top-2 right-2 z-10 h-8">
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<pre className="bg-muted min-w-max rounded p-3 pr-12 font-mono text-sm whitespace-pre">{curlExample}</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ContactUsView
|
||||
className="mt-4 rounded-md border px-3 py-8"
|
||||
icon={<KeyRound size={48} />}
|
||||
title="Scope Based API Keys"
|
||||
description="Need granular access control with scope-based API keys? Enterprise customers can create multiple API keys with specific permissions for different services, teams, or environments."
|
||||
readmeLink="https://docs.getbifrost.io/enterprise/api-keys"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ScrollText } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function AuditLogsView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<ScrollText className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock audit logs for better compliance"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/audit-logs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Layers } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function ClusterPage() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<Layers className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock cluster mode to scale reliably"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/clustering"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Database } from "lucide-react";
|
||||
import ContactUsView from "../../views/contactUsView";
|
||||
|
||||
interface EnableToggleProps {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface BigQueryConnectorViewProps {
|
||||
onDelete?: () => void;
|
||||
isDeleting?: boolean;
|
||||
enableToggle?: EnableToggleProps;
|
||||
}
|
||||
|
||||
export default function BigQueryConnectorView(_props: BigQueryConnectorViewProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Content - OSS: paywall only; no delete/save buttons */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex w-full flex-col items-center justify-center py-8">
|
||||
<ContactUsView
|
||||
align="middle"
|
||||
className="mx-auto w-full max-w-lg"
|
||||
icon={<Database className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock native BigQuery data ingestion for analytics"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/bigquery-connector"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Dog } from "lucide-react";
|
||||
import ContactUsView from "../../views/contactUsView";
|
||||
|
||||
interface EnableToggleProps {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DatadogConnectorViewProps {
|
||||
onDelete?: () => void;
|
||||
isDeleting?: boolean;
|
||||
enableToggle?: EnableToggleProps;
|
||||
}
|
||||
|
||||
export default function DatadogConnectorView(_props: DatadogConnectorViewProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Content - OSS: paywall only; no delete/save buttons */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex w-full flex-col items-center justify-center py-8">
|
||||
<ContactUsView
|
||||
align="middle"
|
||||
className="mx-auto w-full max-w-lg"
|
||||
icon={<Dog className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock native Datadog data ingestion for better observability"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/datadog-connector"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Construction } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function GuardrailsConfigurationView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<Construction className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock guardrails for better security"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/guardrails"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Construction } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function guardrailsProviderView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<Construction className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock guardrails for better security"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/guardrails"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LargePayloadConfig } from "@enterprise/lib/types/largePayload";
|
||||
|
||||
export interface LargePayloadSettingsFragmentProps {
|
||||
config: LargePayloadConfig;
|
||||
onConfigChange: (config: LargePayloadConfig) => void;
|
||||
controlsDisabled: boolean;
|
||||
}
|
||||
|
||||
export default function LargePayloadSettingsFragment(_props: LargePayloadSettingsFragmentProps) {
|
||||
return null;
|
||||
}
|
||||
186
ui/app/_fallbacks/enterprise/components/login/loginView.tsx
Normal file
186
ui/app/_fallbacks/enterprise/components/login/loginView.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getErrorMessage, useIsAuthEnabledQuery, useLoginMutation } from "@/lib/store/apis";
|
||||
import { BooksIcon, DiscordLogoIcon, GithubLogoIcon } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const externalLinks = [
|
||||
{
|
||||
title: "Discord Server",
|
||||
url: "https://discord.gg/exN5KAydbU",
|
||||
icon: DiscordLogoIcon,
|
||||
},
|
||||
{
|
||||
title: "GitHub Repository",
|
||||
url: "https://github.com/maximhq/bifrost",
|
||||
icon: GithubLogoIcon,
|
||||
},
|
||||
{
|
||||
title: "Full Documentation",
|
||||
url: "https://docs.getbifrost.ai",
|
||||
icon: BooksIcon,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
];
|
||||
|
||||
export default function LoginView() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { data: isAuthEnabledData, isLoading: isLoadingIsAuthEnabled, error: isAuthEnabledError } = useIsAuthEnabledQuery();
|
||||
const isAuthEnabled = isAuthEnabledData?.is_auth_enabled || false;
|
||||
const hasValidToken = isAuthEnabledData?.has_valid_token || false;
|
||||
const [login, { isLoading: isLoggingIn }] = useLoginMutation();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Check auth status on component mount
|
||||
useEffect(() => {
|
||||
if (isLoadingIsAuthEnabled) {
|
||||
return;
|
||||
}
|
||||
if (isAuthEnabledError) {
|
||||
setErrorMessage("Unable to verify authentication status. Please retry.");
|
||||
return;
|
||||
}
|
||||
if (!isAuthEnabled || hasValidToken) {
|
||||
navigate({ to: "/workspace" });
|
||||
return;
|
||||
}
|
||||
// Auth is enabled but user is not logged in, show login form
|
||||
setIsCheckingAuth(false);
|
||||
}, [isLoadingIsAuthEnabled]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
setIsLoading(true);
|
||||
e.preventDefault();
|
||||
setErrorMessage("");
|
||||
try {
|
||||
await login({ username, password }).unwrap();
|
||||
// Cookie is set automatically by the server response — just navigate
|
||||
navigate({ to: "/workspace" });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Use light logo for SSR to avoid hydration mismatch
|
||||
const logoSrc = mounted && resolvedTheme === "dark" ? "/bifrost-logo-dark.webp" : "/bifrost-logo.webp";
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (isCheckingAuth || isLoadingIsAuthEnabled) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="border-border bg-card w-full space-y-6 rounded-sm border p-8">
|
||||
<div className="flex items-center justify-center">
|
||||
<img src={logoSrc} alt="Bifrost" width={160} height={26} className="" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground text-sm">Checking authentication...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="border-border bg-card w-full space-y-6 rounded-sm border p-8">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center">
|
||||
<img src={logoSrc} alt="Bifrost" width={160} height={26} className="" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-foreground text-lg font-semibold">Welcome back</h1>
|
||||
<p className="text-muted-foreground text-sm">Sign in to your account to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{errorMessage && <div className="bg-destructive/10 text-destructive rounded-sm p-3 text-sm">{errorMessage}</div>}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-sm font-medium">
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="text-sm"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="pr-10 text-sm"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2 transition-colors"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="h-9 w-full text-sm" isLoading={isLoading} disabled={isLoading}>
|
||||
{isLoading || isLoggingIn ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center justify-center gap-4 pt-4">
|
||||
{externalLinks.map((item, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
title={item.title}
|
||||
>
|
||||
<item.icon className="h-5 w-5" size={20} weight="regular" strokeWidth={item.strokeWidth} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ShieldUser } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function MCPAuthConfigView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<ShieldUser className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock MCP Auth Config"
|
||||
description="This feature is a part of the Bifrost enterprise license. Configure authentication for MCP servers to secure your MCP connections."
|
||||
readmeLink="https://docs.getbifrost.ai/mcp/overview"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ToolCase } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function MCPToolGroups() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">MCP tool groups</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure tool groups for MCP servers to organize and govern tools.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-sm border">
|
||||
<div className="flex w-full flex-col items-center justify-center py-16">
|
||||
<ContactUsView
|
||||
className="mx-auto w-full max-w-lg"
|
||||
icon={<ToolCase className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock MCP Tool Groups"
|
||||
description="This feature is a part of the Bifrost enterprise license. Configure tool groups for MCP servers to organize your MCP tools and govern them across your organization."
|
||||
readmeLink="https://docs.getbifrost.ai/mcp/overview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ScanEye } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function PiiRedactorProviderView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<ScanEye className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock PII Redaction for better privacy"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/pii-redactor"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ScanEye } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function PiiRedactorRulesView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<ScanEye className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock PII Redaction for better privacy"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/pii-redactor"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Router } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function PromptDeploymentView(_props?: { omitTitle?: boolean }) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ContactUsView
|
||||
align="top"
|
||||
className="justify-start gap-3 rounded-md border p-4"
|
||||
icon={<Router className="h-8 w-8" strokeWidth={1.5} />}
|
||||
title="Unlock prompt deployments for better prompt versioning and A/B testing."
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/prompt-deployments"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { usePromptContext } from "@/components/prompts/context";
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import PromptDeploymentView from "./promptDeploymentView";
|
||||
|
||||
export type SettingsSidebarSection = "parameters" | "deployments";
|
||||
|
||||
export function PromptDeploymentsAccordionItem({ activeSection }: { activeSection: SettingsSidebarSection | undefined }) {
|
||||
const { selectedPromptId } = usePromptContext();
|
||||
if (!selectedPromptId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deploymentsOpen = activeSection === "deployments";
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
value="deployments"
|
||||
className={cn(
|
||||
"border-border/60 flex min-h-0 flex-col border-b-0 border-t pt-1",
|
||||
deploymentsOpen ? "min-h-0 grow overflow-hidden" : "shrink-0 grow-0",
|
||||
)}
|
||||
>
|
||||
<AccordionTrigger
|
||||
data-testid="prompt-deployments-trigger"
|
||||
className="text-muted-foreground w-full min-w-0 shrink-0 py-3 pr-1 text-xs font-medium uppercase hover:no-underline [&[data-state=open]>svg]:rotate-180"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-left font-semibold">Deployments</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
containerClassName="data-[state=open]:flex data-[state=open]:min-h-0 data-[state=open]:flex-1 data-[state=open]:flex-col"
|
||||
className="min-h-0 flex-1 overflow-y-auto pt-0 pb-2"
|
||||
>
|
||||
<PromptDeploymentView omitTitle />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
16
ui/app/_fallbacks/enterprise/components/rbac/rbacView.tsx
Normal file
16
ui/app/_fallbacks/enterprise/components/rbac/rbacView.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { UserRoundCheck } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function RBACView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<UserRoundCheck className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock roles and permissions for better security"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
ui/app/_fallbacks/enterprise/components/scim/scimView.tsx
Normal file
16
ui/app/_fallbacks/enterprise/components/scim/scimView.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BookUser } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function SCIMView() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<BookUser className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock SCIM based access management for user provisioning"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Building2 } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export function BusinessUnitsView() {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
testIdPrefix="business-units-governance"
|
||||
icon={<Building2 className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock business units & advanced governance"
|
||||
description="Manage users, business units with our enterprise-grade governance. This feature is part of the Bifrost enterprise license."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import TeamsTable from "@/app/workspace/governance/views/teamsTable";
|
||||
import FullPageLoader from "@/components/fullPageLoader";
|
||||
import { useDebouncedValue } from "@/hooks/useDebounce";
|
||||
import { getErrorMessage, useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const POLLING_INTERVAL = 5000;
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function TeamsView() {
|
||||
const hasVirtualKeysAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.View);
|
||||
const hasCustomersAccess = useRbac(RbacResource.Customers, RbacOperation.View);
|
||||
const hasTeamsAccess = useRbac(RbacResource.Teams, RbacOperation.View);
|
||||
const shownErrorsRef = useRef(new Set<string>());
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const debouncedSearch = useDebouncedValue(search, 300);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const {
|
||||
data: virtualKeysData,
|
||||
error: vkError,
|
||||
isLoading: vkLoading,
|
||||
} = useGetVirtualKeysQuery(undefined, {
|
||||
skip: !hasVirtualKeysAccess,
|
||||
pollingInterval: POLLING_INTERVAL,
|
||||
});
|
||||
const {
|
||||
data: customersData,
|
||||
error: customersError,
|
||||
isLoading: customersLoading,
|
||||
} = useGetCustomersQuery(undefined, {
|
||||
skip: !hasCustomersAccess,
|
||||
pollingInterval: POLLING_INTERVAL,
|
||||
});
|
||||
const {
|
||||
data: teamsData,
|
||||
error: teamsError,
|
||||
isLoading: teamsLoading,
|
||||
} = useGetTeamsQuery(
|
||||
{
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
search: debouncedSearch || undefined,
|
||||
},
|
||||
{
|
||||
skip: !hasTeamsAccess,
|
||||
pollingInterval: POLLING_INTERVAL,
|
||||
},
|
||||
);
|
||||
|
||||
const teamsTotal = teamsData?.total_count ?? 0;
|
||||
|
||||
// Snap offset back when total shrinks past current page (e.g. delete last item on last page)
|
||||
useEffect(() => {
|
||||
if (!teamsData || offset < teamsTotal) return;
|
||||
setOffset(teamsTotal === 0 ? 0 : Math.floor((teamsTotal - 1) / PAGE_SIZE) * PAGE_SIZE);
|
||||
}, [teamsTotal, offset]);
|
||||
|
||||
const isLoading = vkLoading || customersLoading || teamsLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (!vkError && !customersError && !teamsError) {
|
||||
shownErrorsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
const errorKey = `${!!vkError}-${!!customersError}-${!!teamsError}`;
|
||||
if (shownErrorsRef.current.has(errorKey)) return;
|
||||
shownErrorsRef.current.add(errorKey);
|
||||
if (vkError && customersError && teamsError) {
|
||||
toast.error("Failed to load governance data.");
|
||||
} else {
|
||||
if (vkError) toast.error(`Failed to load virtual keys: ${getErrorMessage(vkError)}`);
|
||||
if (customersError) toast.error(`Failed to load customers: ${getErrorMessage(customersError)}`);
|
||||
if (teamsError) toast.error(`Failed to load teams: ${getErrorMessage(teamsError)}`);
|
||||
}
|
||||
}, [vkError, customersError, teamsError]);
|
||||
|
||||
if (isLoading) {
|
||||
return <FullPageLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<TeamsTable
|
||||
teams={teamsData?.teams || []}
|
||||
totalCount={teamsData?.total_count || 0}
|
||||
customers={customersData?.customers || []}
|
||||
virtualKeys={virtualKeysData?.virtual_keys || []}
|
||||
search={search}
|
||||
debouncedSearch={debouncedSearch}
|
||||
onSearchChange={setSearch}
|
||||
offset={offset}
|
||||
limit={PAGE_SIZE}
|
||||
onOffsetChange={setOffset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Users } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function UsersView() {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<Users className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock users & user governance"
|
||||
description="Manage users, set per-user budgets and rate limits, and control access with enterprise-grade governance. This feature is part of the Bifrost enterprise license."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/advanced-governance"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Users } from "lucide-react";
|
||||
import ContactUsView from "../views/contactUsView";
|
||||
|
||||
export default function UserRankingsTab() {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ContactUsView
|
||||
className="mx-auto min-h-[80vh]"
|
||||
icon={<Users className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />}
|
||||
title="Unlock user rankings for better visibility"
|
||||
description="This feature is a part of the Bifrost enterprise license. We would love to know more about your use case and how we can help you."
|
||||
readmeLink="https://docs.getbifrost.ai/enterprise/user-rankings"
|
||||
testIdPrefix="user-rankings"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
readmeLink: string;
|
||||
align?: "middle" | "top";
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export default function ContactUsView({ icon, title, description, className, readmeLink, align = "middle", testIdPrefix }: Props) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center gap-4 text-center", align === "middle" ? "justify-center" : "justify-start", className)}>
|
||||
<div className="text-muted-foreground">{icon}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-muted-foreground text-xl font-medium">{title}</h1>
|
||||
<div className="text-muted-foreground mt-2 max-w-[600px] text-sm font-normal">{description}</div>
|
||||
<div className="mx-auto flex flex-row items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Read more about this feature (opens in new tab)"
|
||||
className="mx-auto mt-6"
|
||||
data-testid={testIdPrefix ? `${testIdPrefix}-read-more` : undefined}
|
||||
onClick={() => {
|
||||
window.open(`${readmeLink}?utm_source=bfd`, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
className="mx-auto mt-6"
|
||||
aria-label="Book a demo (opens Calendly in new tab)"
|
||||
data-testid={testIdPrefix ? `${testIdPrefix}-book-demo` : undefined}
|
||||
onClick={() => {
|
||||
window.open("https://calendly.com/maximai/bifrost-demo?utm_source=bfd_ent", "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
Book a demo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx
Normal file
84
ui/app/_fallbacks/enterprise/lib/contexts/rbacContext.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// RBAC Resource Names (must match backend definitions)
|
||||
export enum RbacResource {
|
||||
GuardrailsConfig = "GuardrailsConfig",
|
||||
GuardrailsProviders = "GuardrailsProviders",
|
||||
GuardrailRules = "GuardrailRules",
|
||||
UserProvisioning = "UserProvisioning",
|
||||
Cluster = "Cluster",
|
||||
Settings = "Settings",
|
||||
Users = "Users",
|
||||
Logs = "Logs",
|
||||
Observability = "Observability",
|
||||
VirtualKeys = "VirtualKeys",
|
||||
ModelProvider = "ModelProvider",
|
||||
Plugins = "Plugins",
|
||||
MCPGateway = "MCPGateway",
|
||||
AdaptiveRouter = "AdaptiveRouter",
|
||||
AuditLogs = "AuditLogs",
|
||||
Customers = "Customers",
|
||||
Teams = "Teams",
|
||||
RBAC = "RBAC",
|
||||
Governance = "Governance",
|
||||
RoutingRules = "RoutingRules",
|
||||
PIIRedactor = "PIIRedactor",
|
||||
PromptRepository = "PromptRepository",
|
||||
PromptDeploymentStrategy = "PromptDeploymentStrategy",
|
||||
AccessProfiles = "AccessProfiles",
|
||||
}
|
||||
|
||||
// RBAC Operation Names (must match backend definitions)
|
||||
export enum RbacOperation {
|
||||
Read = "Read",
|
||||
View = "View",
|
||||
Create = "Create",
|
||||
Update = "Update",
|
||||
Delete = "Delete",
|
||||
Download = "Download",
|
||||
}
|
||||
|
||||
interface RbacContextType {
|
||||
isAllowed: (resource: RbacResource, operation: RbacOperation) => boolean;
|
||||
permissions: Record<string, Record<string, boolean>>;
|
||||
isLoading: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
const RbacContext = createContext<RbacContextType | null>(null);
|
||||
|
||||
// Dummy provider that allows all permissions
|
||||
export function RbacProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RbacContext.Provider
|
||||
value={{
|
||||
isAllowed: () => true, // Always allow in OSS
|
||||
permissions: {},
|
||||
isLoading: false,
|
||||
refetch: () => {},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RbacContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook that always returns true (no restrictions in OSS)
|
||||
export function useRbac(_resource: RbacResource, _operation: RbacOperation): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hook to access full RBAC context
|
||||
export function useRbacContext() {
|
||||
const context = useContext(RbacContext);
|
||||
if (!context) {
|
||||
// Return dummy values if used outside provider
|
||||
return {
|
||||
isAllowed: () => true,
|
||||
permissions: {},
|
||||
isLoading: false,
|
||||
refetch: () => {},
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
25
ui/app/_fallbacks/enterprise/lib/index.ts
Normal file
25
ui/app/_fallbacks/enterprise/lib/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Fallback exports for non-enterprise builds
|
||||
export * from "./store";
|
||||
|
||||
// Re-export OAuth token management utilities for convenience (fallback no-ops)
|
||||
export {
|
||||
REFRESH_TOKEN_ENDPOINT,
|
||||
clearOAuthStorage,
|
||||
clearUserInfo,
|
||||
getAccessToken,
|
||||
getRefreshState,
|
||||
getRefreshToken,
|
||||
getTokenExpiry,
|
||||
getUserInfo,
|
||||
isTokenExpired,
|
||||
setOAuthTokens,
|
||||
setRefreshState,
|
||||
setUserInfo,
|
||||
type UserInfo,
|
||||
} from "./store/utils/tokenManager";
|
||||
|
||||
// Re-export base query (fallback passthrough)
|
||||
export { createBaseQueryWithRefresh } from "./store/utils/baseQueryWithRefresh";
|
||||
|
||||
// Re-export RBAC context (dummy implementation for OSS)
|
||||
export * from "./contexts/rbacContext";
|
||||
@@ -0,0 +1,18 @@
|
||||
import { GetUserAccessProfilesResponse } from "@enterprise/lib/types/accessProfile";
|
||||
|
||||
// OSS build has no access-profile backend — return undefined data so consumers
|
||||
// (e.g. useVirtualKeyUsage) fall back to VK-owned budget/rate-limit values.
|
||||
export const useGetUserAccessProfilesQuery = (
|
||||
_userId: string,
|
||||
_opts?: { skip?: boolean; pollingInterval?: number },
|
||||
): {
|
||||
data: GetUserAccessProfilesResponse | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
} => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
});
|
||||
11
ui/app/_fallbacks/enterprise/lib/store/apis/index.ts
Normal file
11
ui/app/_fallbacks/enterprise/lib/store/apis/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Placeholder for enterprise APIs
|
||||
// Export empty objects when enterprise features are not available
|
||||
|
||||
export const scimApi = null;
|
||||
export const guardrailsApi = null;
|
||||
export const clusterApi = null;
|
||||
export const rbacApi = null;
|
||||
export const auditLogsApi = null;
|
||||
|
||||
// Empty apis array when enterprise features are not available
|
||||
export const apis = [];
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LargePayloadConfig } from "@enterprise/lib/types/largePayload";
|
||||
|
||||
export const useGetLargePayloadConfigQuery = (): {
|
||||
data: LargePayloadConfig | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
} => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
export const useUpdateLargePayloadConfigMutation = (): [
|
||||
(_config: LargePayloadConfig) => { unwrap: () => Promise<void> },
|
||||
{ isLoading: boolean },
|
||||
] => [() => ({ unwrap: async () => {} }), { isLoading: false }];
|
||||
@@ -0,0 +1,22 @@
|
||||
import { User } from "@enterprise/lib/types/user";
|
||||
|
||||
export interface GetVirtualKeyUsersResponse {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
// OSS build has no VK-user-attachment backend — return undefined data so the
|
||||
// consumer treats the VK as unassigned (no AP-managed detection happens).
|
||||
export const useGetVirtualKeyUsersQuery = (
|
||||
_vkId: string,
|
||||
_opts?: { skip?: boolean },
|
||||
): {
|
||||
data: GetVirtualKeyUsersResponse | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
} => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
});
|
||||
23
ui/app/_fallbacks/enterprise/lib/store/index.ts
Normal file
23
ui/app/_fallbacks/enterprise/lib/store/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Fallback exports for non-enterprise builds
|
||||
export * from "./apis";
|
||||
export * from "./slices";
|
||||
|
||||
// Export OAuth token management utilities (fallback no-ops)
|
||||
export {
|
||||
REFRESH_TOKEN_ENDPOINT,
|
||||
clearOAuthStorage,
|
||||
clearUserInfo,
|
||||
getAccessToken,
|
||||
getRefreshState,
|
||||
getRefreshToken,
|
||||
getTokenExpiry,
|
||||
getUserInfo,
|
||||
isTokenExpired,
|
||||
setOAuthTokens,
|
||||
setRefreshState,
|
||||
setUserInfo,
|
||||
type UserInfo,
|
||||
} from "./utils/tokenManager";
|
||||
|
||||
// Export base query (fallback passthrough)
|
||||
export { createBaseQueryWithRefresh } from "./utils/baseQueryWithRefresh";
|
||||
12
ui/app/_fallbacks/enterprise/lib/store/slices/index.ts
Normal file
12
ui/app/_fallbacks/enterprise/lib/store/slices/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Placeholder for enterprise reducers
|
||||
// Export noop reducers when enterprise features are not available
|
||||
|
||||
export const scimReducer = (state = {}) => state;
|
||||
export const userReducer = (state = {}) => state;
|
||||
export const guardrailReducer = (state = {}) => state;
|
||||
|
||||
// Empty reducers map when enterprise features are not available
|
||||
export const reducers = {};
|
||||
|
||||
// Empty enterprise state type when enterprise features are not available
|
||||
export type EnterpriseState = {};
|
||||
@@ -0,0 +1,13 @@
|
||||
// Fallback base query for non-enterprise builds
|
||||
// Simply passes through the base query without any refresh logic
|
||||
|
||||
import type { BaseQueryFn } from "@reduxjs/toolkit/query/react";
|
||||
|
||||
/**
|
||||
* Fallback base query wrapper that does nothing
|
||||
* Used when enterprise features are not available
|
||||
*/
|
||||
export function createBaseQueryWithRefresh(baseQuery: BaseQueryFn): BaseQueryFn {
|
||||
// Simply return the base query as-is (no refresh logic)
|
||||
return baseQuery;
|
||||
}
|
||||
77
ui/app/_fallbacks/enterprise/lib/store/utils/tokenManager.ts
Normal file
77
ui/app/_fallbacks/enterprise/lib/store/utils/tokenManager.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// Fallback OAuth Token Manager for non-enterprise builds
|
||||
// These functions return null/no-op when enterprise features are not available
|
||||
|
||||
export const getAccessToken = async (): Promise<string | null> => Promise.resolve(null);
|
||||
|
||||
export const getRefreshToken = async (): Promise<string | null> => Promise.resolve(null);
|
||||
|
||||
export const getTokenExpiry = (): number | null => null;
|
||||
|
||||
export const isTokenExpired = (): boolean => false;
|
||||
|
||||
export const setOAuthTokens = async (_accessToken: string, _expiresIn?: number | null) => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
|
||||
export const clearOAuthStorage = () => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
|
||||
export const getRefreshState = () => ({
|
||||
isRefreshing: false,
|
||||
refreshPromise: null,
|
||||
});
|
||||
|
||||
export const setRefreshState = (_refreshing: boolean, _promise: Promise<any> | null = null) => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
|
||||
export const REFRESH_TOKEN_ENDPOINT = "";
|
||||
|
||||
// User info type definition (matching enterprise version)
|
||||
export interface UserInfo {
|
||||
name?: string;
|
||||
email?: string;
|
||||
picture?: string;
|
||||
preferred_username?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
}
|
||||
|
||||
// Fallback getUserInfo that returns null for non-enterprise builds
|
||||
export const getUserInfo = (): UserInfo | null => null;
|
||||
|
||||
// Fallback setUserInfo - no-op
|
||||
export const setUserInfo = (_userInfo: UserInfo) => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
|
||||
// Fallback clearUserInfo - no-op
|
||||
export const clearUserInfo = () => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
|
||||
// Fallback secure storage functions - no-op
|
||||
export const setSecureItem = async (key: string, value: string): Promise<void> => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
|
||||
export const getSecureItem = async (key: string): Promise<string | null> => Promise.resolve(null);
|
||||
|
||||
export const removeSecureItem = (key: string): void => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
|
||||
export const setSecureLocalItem = async (key: string, value: string): Promise<void> => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
|
||||
export const getSecureLocalItem = async (key: string): Promise<string | null> => Promise.resolve(null);
|
||||
|
||||
export const removeSecureLocalItem = (key: string): void => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
|
||||
export const clearEncryptionKey = (): void => {
|
||||
// No-op in non-enterprise builds
|
||||
};
|
||||
41
ui/app/_fallbacks/enterprise/lib/types/accessProfile.ts
Normal file
41
ui/app/_fallbacks/enterprise/lib/types/accessProfile.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface AccessProfileBudgetLine {
|
||||
id: string;
|
||||
scope: string;
|
||||
max_limit: number;
|
||||
reset_duration: string;
|
||||
current_usage: number;
|
||||
last_reset: string;
|
||||
alert_thresholds?: number[];
|
||||
}
|
||||
|
||||
export interface AccessProfileRateLimitLine {
|
||||
token_max_limit?: number;
|
||||
token_reset_duration?: string;
|
||||
token_current_usage?: number;
|
||||
token_last_reset?: string;
|
||||
request_max_limit?: number;
|
||||
request_reset_duration?: string;
|
||||
request_current_usage?: number;
|
||||
request_last_reset?: string;
|
||||
}
|
||||
|
||||
export interface UserAccessProfile {
|
||||
id: number;
|
||||
user_id: string;
|
||||
parent_profile_id?: number;
|
||||
virtual_key_ids?: string[];
|
||||
virtual_key_values?: Record<string, string>;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
expires_at?: string;
|
||||
provider_configs?: unknown[];
|
||||
budgets?: AccessProfileBudgetLine[];
|
||||
rate_limit?: AccessProfileRateLimitLine;
|
||||
mcp_configs?: unknown;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GetUserAccessProfilesResponse {
|
||||
access_profiles: UserAccessProfile[];
|
||||
}
|
||||
17
ui/app/_fallbacks/enterprise/lib/types/largePayload.ts
Normal file
17
ui/app/_fallbacks/enterprise/lib/types/largePayload.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface LargePayloadConfig {
|
||||
enabled: boolean;
|
||||
request_threshold_bytes: number;
|
||||
response_threshold_bytes: number;
|
||||
prefetch_size_bytes: number;
|
||||
max_payload_bytes: number;
|
||||
truncated_log_bytes: number;
|
||||
}
|
||||
|
||||
export const DefaultLargePayloadConfig: LargePayloadConfig = {
|
||||
enabled: false,
|
||||
request_threshold_bytes: 10 * 1024 * 1024, // 10MB
|
||||
response_threshold_bytes: 10 * 1024 * 1024, // 10MB
|
||||
prefetch_size_bytes: 64 * 1024, // 64KB
|
||||
max_payload_bytes: 500 * 1024 * 1024, // 500MB
|
||||
truncated_log_bytes: 1024 * 1024, // 1MB
|
||||
};
|
||||
30
ui/app/_fallbacks/enterprise/lib/types/user.ts
Normal file
30
ui/app/_fallbacks/enterprise/lib/types/user.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { UserAccessProfile } from "@enterprise/lib/types/accessProfile";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role_id?: number;
|
||||
role?: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
is_system_role: boolean;
|
||||
};
|
||||
profile?: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
claims?: Record<string, unknown>;
|
||||
access_profile?: UserAccessProfile;
|
||||
teams?: Array<{ id: string; name: string; business_unit_id?: string; business_unit_name?: string }>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GetUsersResponse {
|
||||
users: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
84
ui/app/clientLayout.tsx
Normal file
84
ui/app/clientLayout.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import FullPageLoader from "@/components/fullPageLoader";
|
||||
import NotAvailableBanner from "@/components/notAvailableBanner";
|
||||
import ProgressProvider from "@/components/progressBar";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import { ThemeProvider } from "@/components/themeProvider";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { useStoreSync } from "@/hooks/useStoreSync";
|
||||
import { WebSocketProvider } from "@/hooks/useWebSocket";
|
||||
import { getErrorMessage, ReduxProvider, useGetCoreConfigQuery } from "@/lib/store";
|
||||
import { BifrostConfig } from "@/lib/types/config";
|
||||
import { RbacProvider } from "@enterprise/lib/contexts/rbacContext";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import { NuqsAdapter } from "nuqs/adapters/tanstack-router";
|
||||
import { Suspense, lazy, useEffect } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { toast, Toaster } from "sonner";
|
||||
|
||||
// Lazy import — only loaded in development, completely excluded from prod bundle
|
||||
const DevProfilerLazy = lazy(() => import("@/components/devProfiler").then((mod) => ({ default: mod.DevProfiler })));
|
||||
const DevProfiler = () => (
|
||||
<Suspense fallback={null}>
|
||||
<DevProfilerLazy />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
function StoreSyncInitializer() {
|
||||
useStoreSync();
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppContent({ children }: { children: React.ReactNode }) {
|
||||
const { data: bifrostConfig, error, isLoading } = useGetCoreConfigQuery({});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<WebSocketProvider>
|
||||
<CookiesProvider>
|
||||
<StoreSyncInitializer />
|
||||
<SidebarProvider>
|
||||
<Sidebar />
|
||||
<div className="dark:bg-card custom-scrollbar content-container my-[0.5rem] mr-[0.5rem] h-[calc(100dvh-1rem)] w-full min-w-xl overflow-auto rounded-md border border-gray-200 bg-white px-10 dark:border-zinc-800">
|
||||
<main className="custom-scrollbar content-container-inner relative mx-auto flex flex-col overflow-y-hidden p-4">
|
||||
{isLoading ? <FullPageLoader /> : <FullPage config={bifrostConfig}>{children}</FullPage>}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</CookiesProvider>
|
||||
</WebSocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function FullPage({ config, children }: { config: BifrostConfig | undefined; children: React.ReactNode }) {
|
||||
const pathname = useLocation({ select: (l) => l.pathname });
|
||||
if (config && config.is_db_connected) {
|
||||
return children;
|
||||
}
|
||||
if (config && config.is_logs_connected && pathname.startsWith("/workspace/logs")) {
|
||||
return children;
|
||||
}
|
||||
return <NotAvailableBanner />;
|
||||
}
|
||||
|
||||
export function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ProgressProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<Toaster />
|
||||
<ReduxProvider>
|
||||
<NuqsAdapter>
|
||||
<RbacProvider>
|
||||
<AppContent>{children}</AppContent>
|
||||
{process.env.NODE_ENV === "development" && !process.env.BIFROST_DISABLE_PROFILER && <DevProfiler />}
|
||||
</RbacProvider>
|
||||
</NuqsAdapter>
|
||||
</ReduxProvider>
|
||||
</ThemeProvider>
|
||||
</ProgressProvider>
|
||||
);
|
||||
}
|
||||
366
ui/app/globals.css
Normal file
366
ui/app/globals.css
Normal file
@@ -0,0 +1,366 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../app/**/*.tsx";
|
||||
@source "../node_modules/streamdown/dist/*.js";
|
||||
@source "../../../bifrost-enterprise/ui/**/*.tsx";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ─── Local Geist fonts ───────────────────────────────────────────────────
|
||||
* Variable woff2 files self-hosted from public/static/fonts/.
|
||||
* Self-hosted variable woff2 files, replaces @fontsource-variable/geist.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src:
|
||||
url("/static/fonts/Geist-Variable.woff2") format("woff2-variations"),
|
||||
url("/static/fonts/Geist-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src:
|
||||
url("/static/fonts/Geist-Italic-Variable.woff2") format("woff2-variations"),
|
||||
url("/static/fonts/Geist-Italic-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Geist Mono";
|
||||
src:
|
||||
url("/static/fonts/GeistMono-Variable.woff2") format("woff2-variations"),
|
||||
url("/static/fonts/GeistMono-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Geist Mono";
|
||||
src:
|
||||
url("/static/fonts/GeistMono-Italic-Variable.woff2") format("woff2-variations"),
|
||||
url("/static/fonts/GeistMono-Italic-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-geist-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Roboto", sans-serif;
|
||||
--font-geist-mono: "Geist Mono", ui-monospace, "SFMono-Regular", "Menlo", "Monaco", "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--height-base: calc(100vh - 130px);
|
||||
|
||||
/* Font size overrides - format: [size, { line-height: value }] */
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: 1rem;
|
||||
--text-sm: 0.825rem;
|
||||
--text-sm--line-height: 1.25rem;
|
||||
--text-base: 0.95rem;
|
||||
--text-base--line-height: 1.5rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-lg--line-height: 1.75rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: 1.75rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-2xl--line-height: 2rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-3xl--line-height: 2.25rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-4xl--line-height: 2.5rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-5xl--line-height: 1;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-6xl--line-height: 1;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-7xl--line-height: 1;
|
||||
--text-8xl: 6rem;
|
||||
--text-8xl--line-height: 1;
|
||||
--text-9xl: 8rem;
|
||||
--text-9xl--line-height: 1;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--color-cream-100: oklch(0.98 0 0);
|
||||
--background: #f4f4f5;
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.5081 0.1049 165.61);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: color-mix(in oklch, var(--color-cream-100) 20%, transparent);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.94 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-ink-900: oklch(0.141 0.005 285.823);
|
||||
--background: color-mix(in oklch, var(--color-ink-900) 20%, transparent);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: color-mix(in oklch, var(--color-ink-900) 20%, transparent);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-none;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@utility custom-scrollbar {
|
||||
overflow: auto !important;
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
scrollbar-color: rgba(228, 228, 231, 1) transparent; /* Firefox */
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
--custom-scrollbar-width: 8px;
|
||||
--custom-scrollbar-height: 8px;
|
||||
width: var(--custom-scrollbar-width, 8px);
|
||||
height: var(--custom-scrollbar-height, 8px);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
--tw-bg-opacity: 1 !important;
|
||||
background-color: rgba(228, 228, 231, var(--tw-bg-opacity)) !important;
|
||||
border-radius: 8px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
--tw-bg-opacity: 1 !important;
|
||||
background-color: rgba(82, 82, 91, var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
/* For older WebKit browsers */
|
||||
&::-webkit-scrollbar-thumb:horizontal {
|
||||
background-color: rgba(228, 228, 231, var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:vertical {
|
||||
background-color: rgba(228, 228, 231, var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb:horizontal {
|
||||
background-color: rgba(82, 82, 91, var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb:vertical {
|
||||
background-color: rgba(82, 82, 91, var(--tw-bg-opacity)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.query-builder-wrapper {
|
||||
padding: 1rem;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
[data-radix-scroll-area-viewport].no-table > div {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
[data-radix-scroll-area-viewport].viewport-table-height-full > div {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
div.content-container:has(.no-padding-parent) {
|
||||
@apply p-0!;
|
||||
}
|
||||
|
||||
div.content-container main.content-container-inner:has(.no-padding-parent) {
|
||||
@apply p-0!;
|
||||
}
|
||||
|
||||
div.content-container:has(.no-border-parent) {
|
||||
@apply border-0!;
|
||||
}
|
||||
|
||||
/* ReactFlow Controls — follow Bifrost colour schema */
|
||||
|
||||
.react-flow__controls {
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.react-flow__controls-button {
|
||||
background-color: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
fill: var(--foreground);
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.react-flow__controls-button svg {
|
||||
fill: var(--foreground);
|
||||
}
|
||||
|
||||
/* Dark mode — needs !important to beat ReactFlow's bundled specificity */
|
||||
.dark .react-flow__controls {
|
||||
background-color: var(--card) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
.dark .react-flow__controls-button {
|
||||
background-color: var(--card) !important;
|
||||
border-bottom-color: var(--border) !important;
|
||||
fill: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.dark .react-flow__controls-button:hover {
|
||||
background-color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.dark .react-flow__controls-button svg {
|
||||
fill: var(--foreground) !important;
|
||||
}
|
||||
|
||||
/* Dynamic chain: dash period 3+5 = 8 — offset must move exactly one period per loop */
|
||||
@keyframes rf-routing-tree-dynamic-chain-dash {
|
||||
from {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: -8;
|
||||
}
|
||||
}
|
||||
|
||||
.rf-chain-legend-dynamic-dash {
|
||||
animation: rf-routing-tree-dynamic-chain-dash 0.5s linear infinite;
|
||||
}
|
||||
|
||||
.react-flow__edge.rf-chain-edge-dynamic .react-flow__edge-path {
|
||||
stroke-dasharray: 3 5;
|
||||
animation: rf-routing-tree-dynamic-chain-dash 0.5s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rf-chain-legend-dynamic-dash {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.react-flow__edge.rf-chain-edge-dynamic .react-flow__edge-path {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* // Custom styling for streamdown */
|
||||
|
||||
[data-streamdown="code-block"],
|
||||
[data-streamdown="code-block-body"] {
|
||||
@apply rounded-sm!;
|
||||
}
|
||||
23
ui/app/login/layout.tsx
Normal file
23
ui/app/login/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ThemeProvider } from "@/components/themeProvider";
|
||||
import { ReduxProvider } from "@/lib/store/provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { NuqsAdapter } from "nuqs/adapters/tanstack-router";
|
||||
import LoginPage from "./page";
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ReduxProvider>
|
||||
<NuqsAdapter>
|
||||
<div className="bg-background min-h-screen">
|
||||
<LoginPage />
|
||||
</div>
|
||||
</NuqsAdapter>
|
||||
</ReduxProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
9
ui/app/login/page.tsx
Normal file
9
ui/app/login/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import LoginView from "@enterprise/components/login/loginView";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<LoginView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
ui/app/main.tsx
Normal file
34
ui/app/main.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
// Tailwind + global styles (also declares @font-face for local Geist fonts).
|
||||
import "@/app/globals.css";
|
||||
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { ErrorComponent } from "./__error";
|
||||
import { NotFoundComponent } from "./__notFound";
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
scrollRestoration: true,
|
||||
notFoundMode: "root",
|
||||
defaultNotFoundComponent: NotFoundComponent,
|
||||
defaultErrorComponent: ErrorComponent,
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("Root element #root not found");
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
34
ui/app/pprof/layout.tsx
Normal file
34
ui/app/pprof/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ThemeProvider } from "@/components/themeProvider";
|
||||
import { ReduxProvider } from "@/lib/store";
|
||||
import { isDevelopmentMode } from "@/lib/utils/port";
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { Toaster } from "sonner";
|
||||
import PprofPage from "./page";
|
||||
|
||||
function PprofLayout({ children }: { children: React.ReactNode }) {
|
||||
// Only allow access in development mode
|
||||
if (!isDevelopmentMode()) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
<Toaster />
|
||||
<ReduxProvider>
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">{children}</div>
|
||||
</ReduxProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<PprofLayout>
|
||||
<PprofPage />
|
||||
</PprofLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/pprof")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
1230
ui/app/pprof/page.tsx
Normal file
1230
ui/app/pprof/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
16
ui/app/workspace/adaptive-routing/layout.tsx
Normal file
16
ui/app/workspace/adaptive-routing/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import AdaptiveRoutingPage from "./page";
|
||||
|
||||
function RouteComponent() {
|
||||
const hasAdaptiveRouterAccess = useRbac(RbacResource.AdaptiveRouter, RbacOperation.View);
|
||||
if (!hasAdaptiveRouterAccess) {
|
||||
return <NoPermissionView entity="adaptive routing" />;
|
||||
}
|
||||
return <AdaptiveRoutingPage />;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/workspace/adaptive-routing")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
9
ui/app/workspace/adaptive-routing/page.tsx
Normal file
9
ui/app/workspace/adaptive-routing/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import AdaptiveRoutingView from "@enterprise/components/adaptive-routing/adaptiveRoutingView";
|
||||
|
||||
export default function AdaptiveRoutingPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full">
|
||||
<AdaptiveRoutingView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/alert-channels/layout.tsx
Normal file
6
ui/app/workspace/alert-channels/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import AlertChannelsPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/alert-channels")({
|
||||
component: AlertChannelsPage,
|
||||
});
|
||||
9
ui/app/workspace/alert-channels/page.tsx
Normal file
9
ui/app/workspace/alert-channels/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import AlertChannelsView from "@enterprise/components/alert-channels/alertChannelsView";
|
||||
|
||||
export default function AlertChannelsPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<AlertChannelsView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
ui/app/workspace/audit-logs/layout.tsx
Normal file
16
ui/app/workspace/audit-logs/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import AuditLogsPage from "./page";
|
||||
|
||||
function RouteComponent() {
|
||||
const hasAuditLogsAccess = useRbac(RbacResource.AuditLogs, RbacOperation.View);
|
||||
if (!hasAuditLogsAccess) {
|
||||
return <NoPermissionView entity="audit logs" />;
|
||||
}
|
||||
return <AuditLogsPage />;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/workspace/audit-logs")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
9
ui/app/workspace/audit-logs/page.tsx
Normal file
9
ui/app/workspace/audit-logs/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import AuditLogsView from "@enterprise/components/audit-logs/auditLogsView";
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<AuditLogsView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
ui/app/workspace/cluster/layout.tsx
Normal file
16
ui/app/workspace/cluster/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import ClusterPage from "./page";
|
||||
|
||||
function RouteComponent() {
|
||||
const hasClusterAccess = useRbac(RbacResource.Cluster, RbacOperation.View);
|
||||
if (!hasClusterAccess) {
|
||||
return <NoPermissionView entity="cluster configuration" />;
|
||||
}
|
||||
return <ClusterPage />;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/workspace/cluster")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
9
ui/app/workspace/cluster/page.tsx
Normal file
9
ui/app/workspace/cluster/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import ClusterView from "@enterprise/components/cluster/clusterView";
|
||||
|
||||
export default function ClusterPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<ClusterView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/api-keys/layout.tsx
Normal file
6
ui/app/workspace/config/api-keys/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import APIKeysPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/api-keys")({
|
||||
component: APIKeysPage,
|
||||
});
|
||||
9
ui/app/workspace/config/api-keys/page.tsx
Normal file
9
ui/app/workspace/config/api-keys/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import APIKeysView from "@enterprise/components/api-keys/apiKeysIndexView";
|
||||
|
||||
export default function APIKeysPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<APIKeysView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/caching/layout.tsx
Normal file
6
ui/app/workspace/config/caching/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import CachingPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/caching")({
|
||||
component: CachingPage,
|
||||
});
|
||||
9
ui/app/workspace/config/caching/page.tsx
Normal file
9
ui/app/workspace/config/caching/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import CachingView from "../views/cachingView";
|
||||
|
||||
export default function CachingPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<CachingView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/client-settings/layout.tsx
Normal file
6
ui/app/workspace/config/client-settings/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ClientSettingsPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/client-settings")({
|
||||
component: ClientSettingsPage,
|
||||
});
|
||||
9
ui/app/workspace/config/client-settings/page.tsx
Normal file
9
ui/app/workspace/config/client-settings/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import ClientSettingsView from "../views/clientSettingsView";
|
||||
|
||||
export default function ClientSettingsPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<ClientSettingsView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/compatibility/layout.tsx
Normal file
6
ui/app/workspace/config/compatibility/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import CompatibilityPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/compatibility")({
|
||||
component: CompatibilityPage,
|
||||
});
|
||||
9
ui/app/workspace/config/compatibility/page.tsx
Normal file
9
ui/app/workspace/config/compatibility/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import CompatibilityView from "../views/compatibilityView";
|
||||
|
||||
export default function CompatibilityPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<CompatibilityView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
ui/app/workspace/config/large-payload/layout.tsx
Normal file
7
ui/app/workspace/config/large-payload/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/large-payload")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/workspace/config/client-settings" });
|
||||
},
|
||||
});
|
||||
26
ui/app/workspace/config/layout.tsx
Normal file
26
ui/app/workspace/config/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router";
|
||||
import FullPageLoader from "@/components/fullPageLoader";
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { useGetCoreConfigQuery } from "@/lib/store";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import ConfigPage from "./page";
|
||||
|
||||
function RouteComponent() {
|
||||
const hasConfigAccess = useRbac(RbacResource.Settings, RbacOperation.View);
|
||||
const { isLoading } = useGetCoreConfigQuery({ fromDB: true }, { skip: !hasConfigAccess });
|
||||
const childMatches = useChildMatches();
|
||||
|
||||
if (!hasConfigAccess) {
|
||||
return <NoPermissionView entity="configuration" />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <FullPageLoader />;
|
||||
}
|
||||
|
||||
return childMatches.length === 0 ? <ConfigPage /> : <Outlet />;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/workspace/config")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
6
ui/app/workspace/config/logging/layout.tsx
Normal file
6
ui/app/workspace/config/logging/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import LoggingPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/logging")({
|
||||
component: LoggingPage,
|
||||
});
|
||||
9
ui/app/workspace/config/logging/page.tsx
Normal file
9
ui/app/workspace/config/logging/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import LoggingView from "../views/loggingView";
|
||||
|
||||
export default function LoggingPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<LoggingView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/mcp-gateway/layout.tsx
Normal file
6
ui/app/workspace/config/mcp-gateway/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import MCPGatewayPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/mcp-gateway")({
|
||||
component: MCPGatewayPage,
|
||||
});
|
||||
9
ui/app/workspace/config/mcp-gateway/page.tsx
Normal file
9
ui/app/workspace/config/mcp-gateway/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import MCPGatewayView from "../views/mcpView";
|
||||
|
||||
export default function MCPGatewayPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<MCPGatewayView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/observability/layout.tsx
Normal file
6
ui/app/workspace/config/observability/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ObservabilityPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/observability")({
|
||||
component: ObservabilityPage,
|
||||
});
|
||||
9
ui/app/workspace/config/observability/page.tsx
Normal file
9
ui/app/workspace/config/observability/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import ObservabilityView from "../views/observabilityView";
|
||||
|
||||
export default function ObservabilityPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<ObservabilityView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
ui/app/workspace/config/page.tsx
Normal file
21
ui/app/workspace/config/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
// Check permission
|
||||
const hasConfigAccess = useRbac(RbacResource.Settings, RbacOperation.View);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasConfigAccess) {
|
||||
navigate({ to: "/workspace/config/client-settings", replace: true });
|
||||
}
|
||||
}, [hasConfigAccess, navigate]);
|
||||
|
||||
if (!hasConfigAccess) {
|
||||
return <NoPermissionView entity="configuration" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
6
ui/app/workspace/config/performance-tuning/layout.tsx
Normal file
6
ui/app/workspace/config/performance-tuning/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import PerformanceTuningPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/performance-tuning")({
|
||||
component: PerformanceTuningPage,
|
||||
});
|
||||
9
ui/app/workspace/config/performance-tuning/page.tsx
Normal file
9
ui/app/workspace/config/performance-tuning/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import PerformanceTuningView from "../views/performanceTuningView";
|
||||
|
||||
export default function PerformanceTuningPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<PerformanceTuningView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/pricing-config/layout.tsx
Normal file
6
ui/app/workspace/config/pricing-config/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import PricingConfigPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/pricing-config")({
|
||||
component: PricingConfigPage,
|
||||
});
|
||||
9
ui/app/workspace/config/pricing-config/page.tsx
Normal file
9
ui/app/workspace/config/pricing-config/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import ModelSettingsView from "../views/modelSettingsView";
|
||||
|
||||
export default function PricingConfigPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<ModelSettingsView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/proxy/layout.tsx
Normal file
6
ui/app/workspace/config/proxy/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ProxyPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/proxy")({
|
||||
component: ProxyPage,
|
||||
});
|
||||
24
ui/app/workspace/config/proxy/page.tsx
Normal file
24
ui/app/workspace/config/proxy/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IS_ENTERPRISE } from "@/lib/constants/config";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import ProxyView from "../views/proxyView";
|
||||
|
||||
export default function ProxyPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_ENTERPRISE) {
|
||||
navigate({ to: "/workspace/config/client-settings", replace: true });
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
if (!IS_ENTERPRISE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<ProxyView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/config/security/layout.tsx
Normal file
6
ui/app/workspace/config/security/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import SecurityPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/config/security")({
|
||||
component: SecurityPage,
|
||||
});
|
||||
9
ui/app/workspace/config/security/page.tsx
Normal file
9
ui/app/workspace/config/security/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import SecurityView from "../views/securityView";
|
||||
|
||||
export default function SecurityPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<SecurityView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
ui/app/workspace/config/views/cachingView.tsx
Normal file
32
ui/app/workspace/config/views/cachingView.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getErrorMessage, useGetCoreConfigQuery } from "@/lib/store";
|
||||
import PluginsForm from "./pluginsForm";
|
||||
|
||||
export default function CachingView() {
|
||||
const { data: bifrostConfig, isLoading, error: configError } = useGetCoreConfigQuery({ fromDB: true });
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Caching</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure semantic caching for requests.</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-muted-foreground">Loading configuration...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configError !== undefined && (
|
||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||
<p className="text-destructive text-sm font-medium">Failed to load configuration</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{getErrorMessage(configError) || "An unexpected error occurred. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !configError && <PluginsForm isVectorStoreEnabled={bifrostConfig?.is_cache_connected ?? false} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
570
ui/app/workspace/config/views/clientSettingsView.tsx
Normal file
570
ui/app/workspace/config/views/clientSettingsView.tsx
Normal file
@@ -0,0 +1,570 @@
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useGetDroppedRequestsQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig, DefaultGlobalHeaderFilterConfig, GlobalHeaderFilterConfig } from "@/lib/types/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import LargePayloadSettingsFragment from "@enterprise/components/large-payload/largePayloadSettingsFragment";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useGetLargePayloadConfigQuery, useUpdateLargePayloadConfigMutation } from "@enterprise/lib/store/apis/largePayloadApi";
|
||||
import { DefaultLargePayloadConfig, LargePayloadConfig } from "@enterprise/lib/types/largePayload";
|
||||
import { Info, Plus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Security headers that cannot be configured in allowlist/denylist
|
||||
// These headers are always blocked for security reasons regardless of configuration
|
||||
const SECURITY_HEADERS = [
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"host",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"x-bf-api-key",
|
||||
"x-bf-vk",
|
||||
];
|
||||
|
||||
// Helper to check if a header is a security header
|
||||
function isSecurityHeader(header: string): boolean {
|
||||
const h = header.toLowerCase().trim();
|
||||
// Wildcard patterns are not literal security headers
|
||||
if (h.includes("*")) return false;
|
||||
return SECURITY_HEADERS.includes(h);
|
||||
}
|
||||
|
||||
// Helper to compare header filter configs
|
||||
function headerFilterConfigEqual(a?: GlobalHeaderFilterConfig, b?: GlobalHeaderFilterConfig): boolean {
|
||||
const aAllowlist = a?.allowlist || [];
|
||||
const bAllowlist = b?.allowlist || [];
|
||||
const aDenylist = a?.denylist || [];
|
||||
const bDenylist = b?.denylist || [];
|
||||
|
||||
if (aAllowlist.length !== bAllowlist.length || aDenylist.length !== bDenylist.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return aAllowlist.every((v, i) => v === bAllowlist[i]) && aDenylist.every((v, i) => v === bDenylist[i]);
|
||||
}
|
||||
|
||||
// Helper to compare large payload configs
|
||||
function largePayloadConfigEqual(a: LargePayloadConfig, b: LargePayloadConfig): boolean {
|
||||
return (
|
||||
a.enabled === b.enabled &&
|
||||
a.request_threshold_bytes === b.request_threshold_bytes &&
|
||||
a.response_threshold_bytes === b.response_threshold_bytes &&
|
||||
a.prefetch_size_bytes === b.prefetch_size_bytes &&
|
||||
a.max_payload_bytes === b.max_payload_bytes &&
|
||||
a.truncated_log_bytes === b.truncated_log_bytes
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientSettingsView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const [droppedRequests, setDroppedRequests] = useState<number>(0);
|
||||
const { data: droppedRequestsData } = useGetDroppedRequestsQuery();
|
||||
const { data: bifrostConfig, isLoading: isCoreConfigLoading } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading: isSavingCoreConfig }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
|
||||
// Large payload config state
|
||||
const { data: serverLargePayloadConfig, isLoading: isLargePayloadConfigLoading } = useGetLargePayloadConfigQuery();
|
||||
const [updateLargePayloadConfig, { isLoading: isSavingLargePayload }] = useUpdateLargePayloadConfigMutation();
|
||||
const [localLargePayloadConfig, setLocalLargePayloadConfig] = useState<LargePayloadConfig>(DefaultLargePayloadConfig);
|
||||
|
||||
const isQueriesLoading = isCoreConfigLoading || isLargePayloadConfigLoading;
|
||||
const isLoading = isSavingCoreConfig || isSavingLargePayload;
|
||||
|
||||
useEffect(() => {
|
||||
if (droppedRequestsData) {
|
||||
setDroppedRequests(droppedRequestsData.dropped_requests);
|
||||
}
|
||||
}, [droppedRequestsData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setLocalConfig({
|
||||
...config,
|
||||
header_filter_config: config.header_filter_config || DefaultGlobalHeaderFilterConfig,
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverLargePayloadConfig) {
|
||||
setLocalLargePayloadConfig(serverLargePayloadConfig);
|
||||
}
|
||||
}, [serverLargePayloadConfig]);
|
||||
|
||||
const hasCoreConfigChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
return (
|
||||
localConfig.drop_excess_requests !== config.drop_excess_requests ||
|
||||
localConfig.disable_db_pings_in_health !== config.disable_db_pings_in_health ||
|
||||
localConfig.async_job_result_ttl !== config.async_job_result_ttl ||
|
||||
!headerFilterConfigEqual(localConfig.header_filter_config, config.header_filter_config)
|
||||
);
|
||||
}, [config, localConfig]);
|
||||
|
||||
const hasLargePayloadChanges = useMemo(() => {
|
||||
const baseline = serverLargePayloadConfig ?? DefaultLargePayloadConfig;
|
||||
return !largePayloadConfigEqual(localLargePayloadConfig, baseline);
|
||||
}, [serverLargePayloadConfig, localLargePayloadConfig]);
|
||||
|
||||
const hasChanges = hasCoreConfigChanges || hasLargePayloadChanges;
|
||||
|
||||
// Detect security headers in allowlist/denylist
|
||||
const invalidSecurityHeaders = useMemo(() => {
|
||||
const allowlist = localConfig.header_filter_config?.allowlist || [];
|
||||
const denylist = localConfig.header_filter_config?.denylist || [];
|
||||
const invalidInAllowlist = allowlist.filter((h) => h && isSecurityHeader(h));
|
||||
const invalidInDenylist = denylist.filter((h) => h && isSecurityHeader(h));
|
||||
return [...new Set([...invalidInAllowlist, ...invalidInDenylist])];
|
||||
}, [localConfig.header_filter_config]);
|
||||
|
||||
const hasSecurityHeaderError = invalidSecurityHeaders.length > 0;
|
||||
|
||||
const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean | number | string[] | GlobalHeaderFilterConfig) => {
|
||||
setLocalConfig((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleLargePayloadConfigChange = useCallback((newConfig: LargePayloadConfig) => {
|
||||
setLocalLargePayloadConfig(newConfig);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Defense in depth - don't save if security headers are present
|
||||
if (hasSecurityHeaderError) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate large payload config if it has changes
|
||||
if (hasLargePayloadChanges) {
|
||||
const minBytes = 1024;
|
||||
if (
|
||||
localLargePayloadConfig.request_threshold_bytes < minBytes ||
|
||||
localLargePayloadConfig.response_threshold_bytes < minBytes ||
|
||||
localLargePayloadConfig.prefetch_size_bytes < minBytes ||
|
||||
localLargePayloadConfig.max_payload_bytes < minBytes ||
|
||||
localLargePayloadConfig.truncated_log_bytes < minBytes
|
||||
) {
|
||||
toast.error("All byte values must be at least 1024 (1 KB).");
|
||||
return;
|
||||
}
|
||||
if (localLargePayloadConfig.max_payload_bytes < localLargePayloadConfig.request_threshold_bytes) {
|
||||
toast.error("Max payload size must be greater than or equal to the request threshold.");
|
||||
return;
|
||||
}
|
||||
if (localLargePayloadConfig.max_payload_bytes < localLargePayloadConfig.response_threshold_bytes) {
|
||||
toast.error("Max payload size must be greater than or equal to the response threshold.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let coreConfigSaved = false;
|
||||
let largePayloadSaved = false;
|
||||
|
||||
// Save core config if changed
|
||||
if (hasCoreConfigChanges) {
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
// Clean up empty strings from header filter config
|
||||
const cleanedConfig = {
|
||||
...localConfig,
|
||||
header_filter_config: {
|
||||
allowlist: (localConfig.header_filter_config?.allowlist || []).filter((h) => h && h.trim().length > 0),
|
||||
denylist: (localConfig.header_filter_config?.denylist || []).filter((h) => h && h.trim().length > 0),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await updateCoreConfig({ ...bifrostConfig!, client_config: cleanedConfig }).unwrap();
|
||||
coreConfigSaved = true;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to save client config: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save large payload config if changed
|
||||
if (hasLargePayloadChanges) {
|
||||
try {
|
||||
await updateLargePayloadConfig(localLargePayloadConfig).unwrap();
|
||||
largePayloadSaved = true;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to save large payload config: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (coreConfigSaved || largePayloadSaved) {
|
||||
if (largePayloadSaved) {
|
||||
toast.success("Settings updated. Large payload changes require a restart to apply.");
|
||||
} else {
|
||||
toast.success("Client settings updated successfully.");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
bifrostConfig,
|
||||
hasSecurityHeaderError,
|
||||
hasCoreConfigChanges,
|
||||
hasLargePayloadChanges,
|
||||
localConfig,
|
||||
localLargePayloadConfig,
|
||||
updateCoreConfig,
|
||||
updateLargePayloadConfig,
|
||||
]);
|
||||
|
||||
// Header filter list handlers
|
||||
const handleAddAllowlistHeader = useCallback(() => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
allowlist: [...(prev.header_filter_config?.allowlist || []), ""],
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveAllowlistHeader = useCallback((index: number) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
allowlist: (prev.header_filter_config?.allowlist || []).filter((_, i) => i !== index),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAllowlistChange = useCallback((index: number, value: string) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
allowlist: (prev.header_filter_config?.allowlist || []).map((h, i) => (i === index ? lowerValue : h)),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAddDenylistHeader = useCallback(() => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
denylist: [...(prev.header_filter_config?.denylist || []), ""],
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveDenylistHeader = useCallback((index: number) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
denylist: (prev.header_filter_config?.denylist || []).filter((_, i) => i !== index),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleDenylistChange = useCallback((index: number, value: string) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
header_filter_config: {
|
||||
...prev.header_filter_config,
|
||||
denylist: (prev.header_filter_config?.denylist || []).map((h, i) => (i === index ? lowerValue : h)),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Client Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure client behavior and request handling.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Drop Excess Requests */}
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="drop-excess-requests" className="text-sm font-medium">
|
||||
Drop Excess Requests
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If enabled, Bifrost will drop requests that exceed pool capacity.{" "}
|
||||
{localConfig.drop_excess_requests && droppedRequests > 0 ? (
|
||||
<span>
|
||||
Have dropped <b>{droppedRequests} requests</b> since last restart.
|
||||
</span>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="drop-excess-requests"
|
||||
size="md"
|
||||
checked={localConfig.drop_excess_requests}
|
||||
onCheckedChange={(checked) => handleConfigChange("drop_excess_requests", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Disable DB Pings in Health */}
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="disable-db-pings-in-health" className="text-sm font-medium">
|
||||
Disable DB Pings in Health Check
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If enabled, the /health endpoint will skip database connectivity checks and return OK immediately.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-db-pings-in-health"
|
||||
size="md"
|
||||
checked={localConfig.disable_db_pings_in_health}
|
||||
onCheckedChange={(checked) => handleConfigChange("disable_db_pings_in_health", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
{/* Async Job Result TTL */}
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="async-job-result-ttl" className="text-sm font-medium">
|
||||
Async Job Result TTL (seconds)
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Default time-to-live for async job results in seconds. Results are automatically cleaned up after expiry.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="async-job-result-ttl"
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-32"
|
||||
value={localConfig.async_job_result_ttl}
|
||||
onChange={(e) => handleConfigChange("async_job_result_ttl", parseInt(e.target.value) || 0)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
data-testid="client-settings-async-job-result-ttl-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header Filter Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">Header Forwarding</h3>
|
||||
<p className="text-muted-foreground text-sm">Control which extra headers are forwarded to LLM providers.</p>
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" className="w-full rounded-sm border px-4">
|
||||
<AccordionItem value="about-extra-headers">
|
||||
<AccordionTrigger>
|
||||
<span className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
About Header Forwarding
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<div>
|
||||
<p className="mb-2 font-medium">Two ways to forward headers:</p>
|
||||
<ul className="text-muted-foreground list-inside list-disc space-y-1 text-sm">
|
||||
<li>
|
||||
<span className="font-medium">Prefixed headers:</span> Use{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-*</code> prefix. For example,{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-custom-id</code> is forwarded as{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">custom-id</code>.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Direct headers:</span> Any header explicitly added to the allowlist can be forwarded
|
||||
directly without the prefix (e.g.,{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">anthropic-beta</code>).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-medium">How allowlist and denylist work:</p>
|
||||
<ul className="text-muted-foreground list-inside list-disc space-y-1 text-sm">
|
||||
<li>
|
||||
<span className="font-medium">Allowlist empty:</span> Only{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-*</code> prefixed headers are forwarded
|
||||
(default behavior)
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Allowlist configured:</span> Prefixed headers filtered by allowlist, plus any direct
|
||||
header in the allowlist is forwarded
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Denylist:</span> Headers in the denylist are always blocked from forwarding
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Wildcards:</span> Use{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">*</code> at the end of a pattern to match prefixes
|
||||
(e.g., <code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">anthropic-*</code> matches all headers starting
|
||||
with <code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">anthropic-</code>). Use{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">*</code> alone to match all headers.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-medium">Important:</p>
|
||||
<ul className="text-muted-foreground list-inside list-disc space-y-1 text-sm">
|
||||
<li>
|
||||
Allowlist/denylist entries should be the header name <span className="font-medium">without</span> the{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-</code> prefix
|
||||
</li>
|
||||
<li>
|
||||
Example: To allow <code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">x-bf-eh-custom-id</code> or direct{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">custom-id</code>, add{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">custom-id</code> to the allowlist
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="security-note">
|
||||
<AccordionTrigger>
|
||||
<span className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
Security Note
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="text-sm">
|
||||
Some headers are always blocked for security reasons regardless of configuration. These headers cannot be added to the
|
||||
allowlist or denylist:
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 font-mono text-xs">
|
||||
proxy-authorization, cookie, host, content-length, connection, transfer-encoding, x-api-key, x-goog-api-key, x-bf-api-key,
|
||||
x-bf-vk
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Allowlist Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium">Allowlist</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Headers to allow. Enter names without the <code className="bg-muted rounded px-1 font-mono">x-bf-eh-</code> prefix. Any header
|
||||
in this list can also be sent directly without the prefix.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(localConfig.header_filter_config?.allowlist || []).map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="e.g. anthropic-*, custom-id"
|
||||
data-testid="header-filter-allowlist-input"
|
||||
className={cn(
|
||||
"font-mono lowercase",
|
||||
isSecurityHeader(header) &&
|
||||
"border-destructive focus:border-destructive focus-visible:border-destructive focus-visible:ring-destructive/50",
|
||||
)}
|
||||
value={header}
|
||||
onChange={(e) => handleAllowlistChange(index, e.target.value)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveAllowlistHeader(index)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddAllowlistHeader} disabled={!hasSettingsUpdateAccess}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Denylist Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium">Denylist</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Headers to block. Enter names without the <code className="bg-muted rounded px-1 font-mono">x-bf-eh-</code> prefix. Applies to
|
||||
both prefixed and direct header forwarding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(localConfig.header_filter_config?.denylist || []).map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="e.g. x-internal-*"
|
||||
data-testid="header-filter-denylist-input"
|
||||
className={cn(
|
||||
"font-mono lowercase",
|
||||
isSecurityHeader(header) &&
|
||||
"border-destructive focus:border-destructive focus-visible:border-destructive focus-visible:ring-destructive/50",
|
||||
)}
|
||||
value={header}
|
||||
onChange={(e) => handleDenylistChange(index, e.target.value)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveDenylistHeader(index)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddDenylistHeader} disabled={!hasSettingsUpdateAccess}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large Payload Optimization - Enterprise only */}
|
||||
<LargePayloadSettingsFragment
|
||||
config={localLargePayloadConfig}
|
||||
onConfigChange={handleLargePayloadConfigChange}
|
||||
controlsDisabled={isLoading || !hasSettingsUpdateAccess}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
{hasSecurityHeaderError ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button disabled>{isLoading ? "Saving..." : "Save Changes"}</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Remove security header{invalidSecurityHeaders.length > 1 ? "s" : ""}: {invalidSecurityHeaders.join(", ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || isQueriesLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
ui/app/workspace/config/views/compatibilityView.tsx
Normal file
155
ui/app/workspace/config/views/compatibilityView.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CompatConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function CompatibilityView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config?.compat;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localCompatConfig, setLocalCompatConfig] = useState<CompatConfig>(DefaultCoreConfig.compat);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setLocalCompatConfig(config);
|
||||
return;
|
||||
}
|
||||
setLocalCompatConfig(DefaultCoreConfig.compat);
|
||||
}, [config]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const baseline = config ?? DefaultCoreConfig.compat;
|
||||
return (
|
||||
localCompatConfig.convert_text_to_chat !== baseline.convert_text_to_chat ||
|
||||
localCompatConfig.convert_chat_to_responses !== baseline.convert_chat_to_responses ||
|
||||
localCompatConfig.should_drop_params !== baseline.should_drop_params ||
|
||||
localCompatConfig.should_convert_params !== baseline.should_convert_params
|
||||
);
|
||||
}, [config, localCompatConfig]);
|
||||
|
||||
const handleCompatChange = useCallback((field: keyof CompatConfig, value: boolean) => {
|
||||
setLocalCompatConfig((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig,
|
||||
client_config: {
|
||||
...(bifrostConfig.client_config ?? DefaultCoreConfig),
|
||||
compat: localCompatConfig,
|
||||
},
|
||||
}).unwrap();
|
||||
toast.success("Compatibility settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localCompatConfig, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Compatibility</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Configure request conversions and compatibility fallbacks.{" "}
|
||||
<a
|
||||
className="text-primary underline"
|
||||
href="https://docs.getbifrost.ai/features/litellm-compat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="litellm-docs-link"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="compat-convert-text-to-chat" className="text-sm font-medium">
|
||||
Convert Text to Chat
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Convert text completion requests to chat for models that only support chat.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="compat-convert-text-to-chat"
|
||||
data-testid="compat-convert-text-to-chat"
|
||||
size="md"
|
||||
checked={localCompatConfig.convert_text_to_chat}
|
||||
onCheckedChange={(checked) => handleCompatChange("convert_text_to_chat", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="compat-convert-chat-to-responses" className="text-sm font-medium">
|
||||
Convert Chat to Responses
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Convert chat completion requests to responses for models that only support responses.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="compat-convert-chat-to-responses"
|
||||
data-testid="compat-convert-chat-to-responses"
|
||||
size="md"
|
||||
checked={localCompatConfig.convert_chat_to_responses}
|
||||
onCheckedChange={(checked) => handleCompatChange("convert_chat_to_responses", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="compat-should-drop-params" className="text-sm font-medium">
|
||||
Drop Unsupported Params
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Drop unsupported parameters based on model catalog allowlist.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="compat-should-drop-params"
|
||||
data-testid="compat-should-drop-params"
|
||||
size="md"
|
||||
checked={localCompatConfig.should_drop_params}
|
||||
onCheckedChange={(checked) => handleCompatChange("should_drop_params", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="compat-should-convert-params" className="text-sm font-medium">
|
||||
Convert Unsupported Parameter Values
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Converts model parameter values that are not supported by the model.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="compat-should-convert-params"
|
||||
data-testid="compat-should-convert-params"
|
||||
size="md"
|
||||
checked={localCompatConfig.should_convert_params}
|
||||
onCheckedChange={(checked) => handleCompatChange("should_convert_params", checked)}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess} data-testid="compat-save-button">
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
ui/app/workspace/config/views/loggingView.tsx
Normal file
210
ui/app/workspace/config/views/loggingView.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { parseArrayFromText } from "@/lib/utils/array";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function LoggingView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
const [needsRestart, setNeedsRestart] = useState<boolean>(false);
|
||||
const [loggingHeadersText, setLoggingHeadersText] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setLocalConfig(config);
|
||||
setLoggingHeadersText(config.logging_headers?.join(", ") || "");
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
return (
|
||||
localConfig.enable_logging !== config.enable_logging ||
|
||||
localConfig.disable_content_logging !== config.disable_content_logging ||
|
||||
localConfig.log_retention_days !== config.log_retention_days ||
|
||||
localConfig.hide_deleted_virtual_keys_in_filters !== config.hide_deleted_virtual_keys_in_filters ||
|
||||
JSON.stringify(localConfig.logging_headers || []) !== JSON.stringify(config.logging_headers || [])
|
||||
);
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean | number | string[]) => {
|
||||
setLocalConfig((prev) => ({ ...prev, [field]: value }));
|
||||
if (field === "enable_logging" || field === "disable_content_logging") {
|
||||
setNeedsRestart(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoggingHeadersChange = useCallback((value: string) => {
|
||||
setLoggingHeadersText(value);
|
||||
setLocalConfig((prev) => ({ ...prev, logging_headers: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate log retention days
|
||||
if (localConfig.log_retention_days < 1) {
|
||||
toast.error("Log retention days must be at least 1 day");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCoreConfig({ ...bifrostConfig, client_config: localConfig }).unwrap();
|
||||
toast.success("Logging configuration updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Logs Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure logging settings for requests and responses.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable Logs */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="enable-logging" className="text-sm font-medium">
|
||||
Enable Logs
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Enable logging of requests and responses to a SQL database. This can add 40-60mb of overhead to the system memory.
|
||||
{!bifrostConfig?.is_logs_connected && (
|
||||
<span className="text-destructive font-medium"> Requires logs store to be configured and enabled in config.json.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-logging"
|
||||
size="md"
|
||||
checked={localConfig.enable_logging && bifrostConfig?.is_logs_connected}
|
||||
disabled={!bifrostConfig?.is_logs_connected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (bifrostConfig?.is_logs_connected) {
|
||||
handleConfigChange("enable_logging", checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
|
||||
{/* Disable Content Logging - Only show when logging is enabled */}
|
||||
{localConfig.enable_logging && bifrostConfig?.is_logs_connected && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="disable-content-logging" className="text-sm font-medium">
|
||||
Disable Content Logging
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
When enabled, only usage metadata (latency, cost, token count, etc.) will be logged. Request/response content will not be
|
||||
stored.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-content-logging"
|
||||
size="md"
|
||||
checked={localConfig.disable_content_logging}
|
||||
onCheckedChange={(checked) => handleConfigChange("disable_content_logging", checked)}
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Retention Days */}
|
||||
{localConfig.enable_logging && bifrostConfig?.is_logs_connected && (
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="log-retention-days" className="text-sm font-medium">
|
||||
Log Retention Days
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Number of days to retain logs in the database. Minimum is 1 day. Older logs will be automatically deleted.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="log-retention-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={localConfig.log_retention_days}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 1;
|
||||
handleConfigChange("log_retention_days", Math.max(1, value));
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="hide-deleted-virtual-keys-in-filters" className="text-sm font-medium">
|
||||
Do Not Show Deleted VirtualKeys In Filters
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
When enabled, deleted virtual keys are excluded from Virtual Keys filter options in Logs, Dashboard, and MCP Logs.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="hide-deleted-virtual-keys-in-filters"
|
||||
data-testid="hide-deleted-virtual-keys-in-filters-switch"
|
||||
size="md"
|
||||
checked={localConfig.hide_deleted_virtual_keys_in_filters}
|
||||
onCheckedChange={(checked) => handleConfigChange("hide_deleted_virtual_keys_in_filters", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logging Headers */}
|
||||
{localConfig.enable_logging && bifrostConfig?.is_logs_connected && (
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<label htmlFor="logging-headers" className="text-sm font-medium">
|
||||
Logging Headers
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Comma-separated list of request headers to capture in log metadata. Values are extracted from incoming requests and stored in
|
||||
the metadata field of log entries. Headers with the <code className="text-xs">x-bf-lh-</code> prefix are always captured
|
||||
automatically.
|
||||
</p>
|
||||
<Textarea
|
||||
id="logging-headers"
|
||||
data-testid="workspace-logging-headers-textarea"
|
||||
className="h-24"
|
||||
placeholder="X-Tenant-ID, X-Request-Source, X-Correlation-ID"
|
||||
value={loggingHeadersText}
|
||||
onChange={(e) => handleLoggingHeadersChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RestartWarning = () => {
|
||||
return <div className="text-muted-foreground mt-2 pl-4 text-xs font-semibold">Need to restart Bifrost to apply changes.</div>;
|
||||
};
|
||||
334
ui/app/workspace/config/views/mcpView.tsx
Normal file
334
ui/app/workspace/config/views/mcpView.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
getErrorMessage,
|
||||
useGetCoreConfigQuery,
|
||||
useUpdateCoreConfigMutation,
|
||||
} from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function MCPView() {
|
||||
const hasSettingsUpdateAccess = useRbac(
|
||||
RbacResource.Settings,
|
||||
RbacOperation.Update,
|
||||
);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
|
||||
const [localValues, setLocalValues] = useState<{
|
||||
mcp_agent_depth: string;
|
||||
mcp_tool_execution_timeout: string;
|
||||
mcp_code_mode_binding_level: string;
|
||||
mcp_tool_sync_interval: string;
|
||||
}>({
|
||||
mcp_agent_depth: "10",
|
||||
mcp_tool_execution_timeout: "30",
|
||||
mcp_code_mode_binding_level: "server",
|
||||
mcp_tool_sync_interval: "10",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
setLocalConfig(config);
|
||||
setLocalValues({
|
||||
mcp_agent_depth: config?.mcp_agent_depth?.toString() || "10",
|
||||
mcp_tool_execution_timeout:
|
||||
config?.mcp_tool_execution_timeout?.toString() || "30",
|
||||
mcp_code_mode_binding_level:
|
||||
config?.mcp_code_mode_binding_level || "server",
|
||||
mcp_tool_sync_interval:
|
||||
config?.mcp_tool_sync_interval?.toString() || "10",
|
||||
});
|
||||
}
|
||||
}, [config, bifrostConfig]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
return (
|
||||
localConfig.mcp_agent_depth !== config.mcp_agent_depth ||
|
||||
localConfig.mcp_tool_execution_timeout !==
|
||||
config.mcp_tool_execution_timeout ||
|
||||
localConfig.mcp_code_mode_binding_level !==
|
||||
(config.mcp_code_mode_binding_level || "server") ||
|
||||
localConfig.mcp_tool_sync_interval !==
|
||||
(config.mcp_tool_sync_interval ?? 10) ||
|
||||
localConfig.mcp_disable_auto_tool_inject !==
|
||||
(config.mcp_disable_auto_tool_inject ?? false)
|
||||
);
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handleAgentDepthChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, mcp_agent_depth: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
setLocalConfig((prev) => ({ ...prev, mcp_agent_depth: numValue }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToolExecutionTimeoutChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, mcp_tool_execution_timeout: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
mcp_tool_execution_timeout: numValue,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCodeModeBindingLevelChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, mcp_code_mode_binding_level: value }));
|
||||
if (value === "server" || value === "tool") {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
mcp_code_mode_binding_level: value,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToolSyncIntervalChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, mcp_tool_sync_interval: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue >= 0) {
|
||||
setLocalConfig((prev) => ({ ...prev, mcp_tool_sync_interval: numValue }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDisableAutoToolInjectChange = useCallback((checked: boolean) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
mcp_disable_auto_tool_inject: checked,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const agentDepth = Number.parseInt(localValues.mcp_agent_depth);
|
||||
const toolTimeout = Number.parseInt(
|
||||
localValues.mcp_tool_execution_timeout,
|
||||
);
|
||||
|
||||
if (isNaN(agentDepth) || agentDepth <= 0) {
|
||||
toast.error("Max agent depth must be a positive number.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(toolTimeout) || toolTimeout <= 0) {
|
||||
toast.error("Tool execution timeout must be a positive number.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig,
|
||||
client_config: localConfig,
|
||||
}).unwrap();
|
||||
toast.success("MCP settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, localValues, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto w-full max-w-7xl space-y-4"
|
||||
data-testid="mcp-settings-view"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">MCP Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Configure MCP (Model Context Protocol) agent and tool settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Max Agent Depth */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="mcp-agent-depth" className="text-sm font-medium">
|
||||
Max Agent Depth
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Maximum depth for MCP agent execution.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="mcp-agent-depth"
|
||||
data-testid="mcp-agent-depth-input"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.mcp_agent_depth}
|
||||
onChange={(e) => handleAgentDepthChange(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Execution Timeout */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="mcp-tool-execution-timeout"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Tool Execution Timeout (seconds)
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Maximum time in seconds for tool execution.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="mcp-tool-execution-timeout"
|
||||
data-testid="mcp-tool-timeout-input"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.mcp_tool_execution_timeout}
|
||||
onChange={(e) => handleToolExecutionTimeoutChange(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Sync Interval */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="mcp-tool-sync-interval"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Tool Sync Interval (minutes)
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
How often to refresh tool lists from MCP servers. Set to 0 to
|
||||
disable.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="mcp-tool-sync-interval"
|
||||
data-testid="mcp-tool-sync-interval-input"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.mcp_tool_sync_interval}
|
||||
onChange={(e) => handleToolSyncIntervalChange(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Disable Auto Tool Injection */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="mcp-disable-auto-tool-inject"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Disable Auto Tool Injection
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
When enabled, MCP tools are not automatically included in every
|
||||
request. Tools are only injected when explicitly specified via
|
||||
request headers (
|
||||
<code className="text-xs">x-bf-mcp-include-tools</code>) and still
|
||||
must be allowed by the virtual key MCP configuration.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="mcp-disable-auto-tool-inject"
|
||||
checked={localConfig.mcp_disable_auto_tool_inject ?? false}
|
||||
onCheckedChange={handleDisableAutoToolInjectChange}
|
||||
disabled={!hasSettingsUpdateAccess}
|
||||
data-testid="mcp-disable-auto-tool-inject-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Code Mode Binding Level */}
|
||||
<div className="space-y-4 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="mcp-binding-level" className="text-sm font-medium">
|
||||
Code Mode Binding Level
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
How tools are exposed in the VFS: server-level (all tools per
|
||||
server) or tool-level (individual tools).
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={localValues.mcp_code_mode_binding_level}
|
||||
onValueChange={handleCodeModeBindingLevelChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="mcp-binding-level"
|
||||
data-testid="mcp-binding-level"
|
||||
className="w-56"
|
||||
>
|
||||
<SelectValue placeholder="Select binding level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="server">Server-Level</SelectItem>
|
||||
<SelectItem value="tool">Tool-Level</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Visual Example */}
|
||||
<div className="mt-6 space-y-2">
|
||||
<p className="text-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
VFS Structure:
|
||||
</p>
|
||||
|
||||
{localValues.mcp_code_mode_binding_level === "server" ? (
|
||||
<div className="bg-muted border-border rounded-sm border p-4">
|
||||
<div className="text-foreground space-y-1 font-mono text-xs">
|
||||
<div>servers/</div>
|
||||
<div className="pl-3">├─ calculator.py</div>
|
||||
<div className="pl-3">├─ youtube.py</div>
|
||||
<div className="pl-3">└─ weather.py</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
All tools per server in a single .py file
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted border-border rounded-sm border p-4">
|
||||
<div className="text-foreground space-y-1 font-mono text-xs">
|
||||
<div>servers/</div>
|
||||
<div className="pl-3">├─ calculator/</div>
|
||||
<div className="pl-6">├─ add.py</div>
|
||||
<div className="pl-6">└─ subtract.py</div>
|
||||
<div className="pl-3">├─ youtube/</div>
|
||||
<div className="pl-6">├─ GET_CHANNELS.py</div>
|
||||
<div className="pl-6">└─ SEARCH_VIDEOS.py</div>
|
||||
<div className="pl-3">└─ weather/</div>
|
||||
<div className="pl-6">└─ get_forecast.py</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
Individual .py file for each tool
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}
|
||||
data-testid="mcp-settings-save-btn"
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
ui/app/workspace/config/views/modelSettingsView.tsx
Normal file
192
ui/app/workspace/config/views/modelSettingsView.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getErrorMessage, useForcePricingSyncMutation, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ModelSettingsFormData {
|
||||
pricing_datasheet_url: string;
|
||||
pricing_sync_interval_hours: number;
|
||||
routing_chain_max_depth: number;
|
||||
}
|
||||
|
||||
export default function ModelSettingsView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const frameworkConfig = bifrostConfig?.framework_config;
|
||||
const clientConfig = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [forcePricingSync, { isLoading: isForceSyncing }] = useForcePricingSyncMutation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
watch,
|
||||
} = useForm<ModelSettingsFormData>({
|
||||
defaultValues: {
|
||||
pricing_datasheet_url: "",
|
||||
pricing_sync_interval_hours: 24,
|
||||
routing_chain_max_depth: DefaultCoreConfig.routing_chain_max_depth,
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = watch();
|
||||
|
||||
useEffect(() => {
|
||||
if (!bifrostConfig || isDirty) return;
|
||||
reset({
|
||||
pricing_datasheet_url: frameworkConfig?.pricing_url || "",
|
||||
pricing_sync_interval_hours: Math.round((frameworkConfig?.pricing_sync_interval ?? 0) / 3600) || 24,
|
||||
routing_chain_max_depth: clientConfig?.routing_chain_max_depth ?? DefaultCoreConfig.routing_chain_max_depth,
|
||||
});
|
||||
}, [frameworkConfig?.pricing_url, frameworkConfig?.pricing_sync_interval, clientConfig?.routing_chain_max_depth, isDirty, reset]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!bifrostConfig || !isDirty) return false;
|
||||
const serverUrl = frameworkConfig?.pricing_url || "";
|
||||
const serverInterval = Math.round((frameworkConfig?.pricing_sync_interval ?? 0) / 3600);
|
||||
const serverDepth = clientConfig?.routing_chain_max_depth ?? DefaultCoreConfig.routing_chain_max_depth;
|
||||
return (
|
||||
formValues.pricing_datasheet_url !== serverUrl ||
|
||||
formValues.pricing_sync_interval_hours !== serverInterval ||
|
||||
formValues.routing_chain_max_depth !== serverDepth
|
||||
);
|
||||
}, [bifrostConfig, frameworkConfig, clientConfig, formValues, isDirty]);
|
||||
|
||||
const onSubmit = async (data: ModelSettingsFormData) => {
|
||||
try {
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig!,
|
||||
framework_config: {
|
||||
...frameworkConfig,
|
||||
id: bifrostConfig?.framework_config.id || 0,
|
||||
pricing_url: data.pricing_datasheet_url,
|
||||
pricing_sync_interval: data.pricing_sync_interval_hours * 3600,
|
||||
},
|
||||
client_config: {
|
||||
...clientConfig!,
|
||||
routing_chain_max_depth: data.routing_chain_max_depth,
|
||||
},
|
||||
}).unwrap();
|
||||
toast.success("Model settings updated successfully.");
|
||||
reset(data);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceSync = async () => {
|
||||
try {
|
||||
await forcePricingSync().unwrap();
|
||||
toast.success("Pricing sync triggered successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl space-y-4" data-testid="model-settings-view">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Model Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure pricing and routing behaviour.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Pricing Datasheet URL */}
|
||||
<div className="space-y-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="pricing-datasheet-url">Pricing Datasheet URL</Label>
|
||||
<p className="text-muted-foreground text-sm">URL to a custom pricing datasheet. Leave empty to use default pricing.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="pricing-datasheet-url"
|
||||
type="text"
|
||||
placeholder="https://example.com/pricing.json"
|
||||
data-testid="pricing-datasheet-url-input"
|
||||
{...register("pricing_datasheet_url", {
|
||||
pattern: {
|
||||
value: /^(https?:\/\/)?((localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?|([\da-z\.-]+)\.([a-z\.]{2,6}))[\/\w \.-]*\/?$/,
|
||||
message: "Please enter a valid URL.",
|
||||
},
|
||||
validate: {
|
||||
checkIfHttp: (value) => {
|
||||
if (!value) return true;
|
||||
return value.startsWith("http://") || value.startsWith("https://") || "URL must start with http:// or https://";
|
||||
},
|
||||
},
|
||||
})}
|
||||
className={errors.pricing_datasheet_url ? "border-destructive" : ""}
|
||||
/>
|
||||
{errors.pricing_datasheet_url && <p className="text-destructive text-sm">{errors.pricing_datasheet_url.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Pricing Sync Interval */}
|
||||
<div className="space-y-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="pricing-sync-interval">Pricing Sync Interval (hours)</Label>
|
||||
<p className="text-muted-foreground text-sm">How often to sync pricing data from the datasheet URL.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="pricing-sync-interval"
|
||||
type="number"
|
||||
data-testid="pricing-sync-interval-input"
|
||||
className={errors.pricing_sync_interval_hours ? "border-destructive" : ""}
|
||||
{...register("pricing_sync_interval_hours", {
|
||||
required: "Pricing sync interval is required",
|
||||
min: { value: 1, message: "Sync interval must be at least 1 hour" },
|
||||
max: { value: 8760, message: "Sync interval cannot exceed 8760 hours (1 year)" },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
{errors.pricing_sync_interval_hours && <p className="text-destructive text-sm">{errors.pricing_sync_interval_hours.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Routing Chain Max Depth */}
|
||||
<div className="flex items-center justify-between rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="routing-chain-max-depth">Routing Chain Max Depth</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Maximum number of chained routing rule evaluations per request. Prevents infinite loops from circular rule definitions.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="routing-chain-max-depth"
|
||||
type="number"
|
||||
className={`w-24 ${errors.routing_chain_max_depth ? "border-destructive" : ""}`}
|
||||
data-testid="routing-chain-max-depth-input"
|
||||
{...register("routing_chain_max_depth", {
|
||||
required: "Routing chain max depth is required",
|
||||
min: { value: 1, message: "Must be at least 1" },
|
||||
max: { value: 100, message: "Cannot exceed 100" },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{errors.routing_chain_max_depth && <p className="text-destructive text-sm">{errors.routing_chain_max_depth.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={handleForceSync}
|
||||
disabled={isForceSyncing || isLoading || hasChanges || !hasSettingsUpdateAccess}
|
||||
data-testid="pricing-force-sync-btn"
|
||||
>
|
||||
{isForceSyncing ? "Syncing..." : "Force Sync Now"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess} data-testid="model-settings-save-btn">
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
ui/app/workspace/config/views/observabilityView.tsx
Normal file
105
ui/app/workspace/config/views/observabilityView.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { parseArrayFromText } from "@/lib/utils/array";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ObservabilityView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
const [needsRestart, setNeedsRestart] = useState<boolean>(false);
|
||||
|
||||
const [localValues, setLocalValues] = useState<{
|
||||
prometheus_labels: string;
|
||||
}>({
|
||||
prometheus_labels: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
setLocalConfig(config);
|
||||
setLocalValues({
|
||||
prometheus_labels: config?.prometheus_labels?.join(", ") || "",
|
||||
});
|
||||
}
|
||||
}, [config, bifrostConfig]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
const localLabels = localConfig.prometheus_labels.slice().sort().join(",");
|
||||
const serverLabels = config.prometheus_labels.slice().sort().join(",");
|
||||
return localLabels !== serverLabels;
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handlePrometheusLabelsChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, prometheus_labels: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, prometheus_labels: parseArrayFromText(value) }));
|
||||
setNeedsRestart(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Could not save settings: configuration not loaded.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateCoreConfig({ ...bifrostConfig, client_config: localConfig }).unwrap();
|
||||
toast.success("Observability settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div className="flex items-center justify-between"></div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
These settings require a Bifrost service restart to take effect. Current connections will continue with existing settings until
|
||||
restart.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Prometheus Labels */}
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="prometheus-labels" className="text-sm font-medium">
|
||||
Prometheus Labels
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Comma-separated list of custom labels to add to the Prometheus metrics.</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="prometheus-labels"
|
||||
className="h-24"
|
||||
placeholder="teamId, projectId, environment"
|
||||
value={localValues.prometheus_labels}
|
||||
onChange={(e) => handlePrometheusLabelsChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RestartWarning = () => {
|
||||
return <div className="text-muted-foreground mt-2 pl-4 text-xs font-semibold">Need to restart Bifrost to apply changes.</div>;
|
||||
};
|
||||
157
ui/app/workspace/config/views/performanceTuningView.tsx
Normal file
157
ui/app/workspace/config/views/performanceTuningView.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function PerformanceTuningView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
const [needsRestart, setNeedsRestart] = useState<boolean>(false);
|
||||
|
||||
const [localValues, setLocalValues] = useState<{
|
||||
initial_pool_size: string;
|
||||
max_request_body_size_mb: string;
|
||||
}>({
|
||||
initial_pool_size: "1000",
|
||||
max_request_body_size_mb: "100",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
setLocalConfig(config);
|
||||
setLocalValues({
|
||||
initial_pool_size: config?.initial_pool_size?.toString() || "1000",
|
||||
max_request_body_size_mb: config?.max_request_body_size_mb?.toString() || "100",
|
||||
});
|
||||
}
|
||||
}, [config, bifrostConfig]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
return (
|
||||
localConfig.initial_pool_size !== config.initial_pool_size || localConfig.max_request_body_size_mb !== config.max_request_body_size_mb
|
||||
);
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handlePoolSizeChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, initial_pool_size: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
setLocalConfig((prev) => ({ ...prev, initial_pool_size: numValue }));
|
||||
}
|
||||
setNeedsRestart(true);
|
||||
}, []);
|
||||
|
||||
const handleMaxRequestBodySizeMBChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, max_request_body_size_mb: value }));
|
||||
const numValue = Number.parseInt(value);
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
setLocalConfig((prev) => ({ ...prev, max_request_body_size_mb: numValue }));
|
||||
}
|
||||
setNeedsRestart(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const poolSize = Number.parseInt(localValues.initial_pool_size);
|
||||
const maxBodySize = Number.parseInt(localValues.max_request_body_size_mb);
|
||||
|
||||
if (isNaN(poolSize) || poolSize <= 0) {
|
||||
toast.error("Initial pool size must be a positive number.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(maxBodySize) || maxBodySize <= 0) {
|
||||
toast.error("Max request body size must be a positive number.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bifrostConfig) {
|
||||
toast.error("Configuration not loaded. Please refresh and try again.");
|
||||
return;
|
||||
}
|
||||
await updateCoreConfig({ ...bifrostConfig, client_config: localConfig }).unwrap();
|
||||
toast.success("Performance settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, localValues, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Performance Tuning</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure performance-related settings.</p>
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
These settings require a Bifrost service restart to take effect. Current connections will continue with existing settings until
|
||||
restart.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Initial Pool Size */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="initial-pool-size" className="text-sm font-medium">
|
||||
Initial Pool Size
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">The initial connection pool size.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="initial-pool-size"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.initial_pool_size}
|
||||
onChange={(e) => handlePoolSizeChange(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
|
||||
{/* Max Request Body Size */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="max-request-body-size-mb" className="text-sm font-medium">
|
||||
Max Request Body Size (MB)
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Maximum size of request body in megabytes.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="max-request-body-size-mb"
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={localValues.max_request_body_size_mb}
|
||||
onChange={(e) => handleMaxRequestBodySizeMBChange(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
{needsRestart && <RestartWarning />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RestartWarning = () => {
|
||||
return <div className="text-muted-foreground mt-2 pl-4 text-xs font-semibold">Need to restart Bifrost to apply changes.</div>;
|
||||
};
|
||||
467
ui/app/workspace/config/views/pluginsForm.tsx
Normal file
467
ui/app/workspace/config/views/pluginsForm.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getProviderLabel } from "@/lib/constants/logs";
|
||||
import { getErrorMessage, useCreatePluginMutation, useGetPluginsQuery, useGetProvidersQuery, useUpdatePluginMutation } from "@/lib/store";
|
||||
import { CacheConfig, EditorCacheConfig, ModelProviderName } from "@/lib/types/config";
|
||||
import { SEMANTIC_CACHE_PLUGIN } from "@/lib/types/plugins";
|
||||
import { cacheConfigSchema } from "@/lib/types/schemas";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const defaultCacheConfig: EditorCacheConfig = {
|
||||
ttl_seconds: 300,
|
||||
threshold: 0.8,
|
||||
conversation_history_threshold: 3,
|
||||
exclude_system_prompt: false,
|
||||
cache_by_model: true,
|
||||
cache_by_provider: true,
|
||||
};
|
||||
|
||||
const toEditorCacheConfig = (config?: Partial<CacheConfig>): EditorCacheConfig => ({
|
||||
...defaultCacheConfig,
|
||||
...config,
|
||||
});
|
||||
|
||||
const normalizeCacheConfigForSave = (config: EditorCacheConfig) => {
|
||||
const normalized: Record<string, unknown> = {
|
||||
ttl_seconds: config.ttl_seconds,
|
||||
threshold: config.threshold,
|
||||
cache_by_model: config.cache_by_model,
|
||||
cache_by_provider: config.cache_by_provider,
|
||||
};
|
||||
|
||||
if (config.conversation_history_threshold !== undefined) {
|
||||
normalized.conversation_history_threshold = config.conversation_history_threshold;
|
||||
}
|
||||
if (config.exclude_system_prompt !== undefined) {
|
||||
normalized.exclude_system_prompt = config.exclude_system_prompt;
|
||||
}
|
||||
if (config.created_at !== undefined) {
|
||||
normalized.created_at = config.created_at;
|
||||
}
|
||||
if (config.updated_at !== undefined) {
|
||||
normalized.updated_at = config.updated_at;
|
||||
}
|
||||
if (config.keys !== undefined) {
|
||||
normalized.keys = config.keys;
|
||||
}
|
||||
|
||||
const provider = config.provider?.trim();
|
||||
const embeddingModel = config.embedding_model?.trim();
|
||||
|
||||
if (provider) {
|
||||
normalized.provider = provider;
|
||||
}
|
||||
if (embeddingModel) {
|
||||
normalized.embedding_model = embeddingModel;
|
||||
}
|
||||
if (config.dimension !== undefined) {
|
||||
normalized.dimension = config.dimension;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
interface PluginsFormProps {
|
||||
isVectorStoreEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function PluginsForm({ isVectorStoreEnabled }: PluginsFormProps) {
|
||||
const [cacheConfig, setCacheConfig] = useState<EditorCacheConfig>(defaultCacheConfig);
|
||||
const [originalCacheEnabled, setOriginalCacheEnabled] = useState<boolean>(false);
|
||||
const [serverCacheConfig, setServerCacheConfig] = useState<EditorCacheConfig>(defaultCacheConfig);
|
||||
const [serverCacheEnabled, setServerCacheEnabled] = useState<boolean>(false);
|
||||
|
||||
const { data: providersData, error: providersError, isLoading: providersLoading } = useGetProvidersQuery();
|
||||
|
||||
const providers = useMemo(() => providersData || [], [providersData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (providersError) {
|
||||
toast.error(`Failed to load providers: ${getErrorMessage(providersError as any)}`);
|
||||
}
|
||||
}, [providersError]);
|
||||
|
||||
// RTK Query hooks
|
||||
const { data: plugins, isLoading: loading } = useGetPluginsQuery();
|
||||
const [updatePlugin, { isLoading: isUpdating }] = useUpdatePluginMutation();
|
||||
const [createPlugin, { isLoading: isCreating }] = useCreatePluginMutation();
|
||||
|
||||
// Get semantic cache plugin and its config
|
||||
const semanticCachePlugin = useMemo(() => plugins?.find((plugin) => plugin.name === SEMANTIC_CACHE_PLUGIN), [plugins]);
|
||||
|
||||
const isSemanticCacheEnabled = Boolean(semanticCachePlugin?.enabled);
|
||||
const loadedDirectOnlyConfig = serverCacheConfig.dimension === 1 && !serverCacheConfig.provider;
|
||||
const hasInvalidProviderBackedDimension = cacheConfig.dimension === 1 && Boolean(cacheConfig.provider?.trim());
|
||||
|
||||
// Initialize cache config from plugin data
|
||||
useEffect(() => {
|
||||
if (semanticCachePlugin?.config) {
|
||||
const config = toEditorCacheConfig(semanticCachePlugin.config as Partial<CacheConfig>);
|
||||
setCacheConfig(config);
|
||||
setServerCacheConfig(config);
|
||||
setOriginalCacheEnabled(semanticCachePlugin.enabled);
|
||||
setServerCacheEnabled(semanticCachePlugin.enabled);
|
||||
}
|
||||
}, [semanticCachePlugin]);
|
||||
|
||||
// Update default provider when providers are loaded (only for new configs)
|
||||
useEffect(() => {
|
||||
if (providers.length > 0 && !semanticCachePlugin?.config) {
|
||||
setCacheConfig((prev) => ({
|
||||
...prev,
|
||||
provider: providers[0].name as ModelProviderName,
|
||||
embedding_model: prev.embedding_model ?? "text-embedding-3-small",
|
||||
dimension: prev.dimension ?? 1536,
|
||||
}));
|
||||
}
|
||||
}, [providers, semanticCachePlugin?.config]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (originalCacheEnabled !== serverCacheEnabled) return true;
|
||||
|
||||
return (
|
||||
cacheConfig.provider !== serverCacheConfig.provider ||
|
||||
cacheConfig.embedding_model !== serverCacheConfig.embedding_model ||
|
||||
cacheConfig.dimension !== serverCacheConfig.dimension ||
|
||||
cacheConfig.ttl_seconds !== serverCacheConfig.ttl_seconds ||
|
||||
cacheConfig.threshold !== serverCacheConfig.threshold ||
|
||||
cacheConfig.conversation_history_threshold !== serverCacheConfig.conversation_history_threshold ||
|
||||
cacheConfig.exclude_system_prompt !== serverCacheConfig.exclude_system_prompt ||
|
||||
cacheConfig.cache_by_model !== serverCacheConfig.cache_by_model ||
|
||||
cacheConfig.cache_by_provider !== serverCacheConfig.cache_by_provider
|
||||
);
|
||||
}, [cacheConfig, serverCacheConfig, originalCacheEnabled, serverCacheEnabled]);
|
||||
|
||||
// Handle semantic cache toggle (create or update)
|
||||
const handleSemanticCacheToggle = (enabled: boolean) => {
|
||||
setOriginalCacheEnabled(enabled);
|
||||
};
|
||||
|
||||
// Update cache config locally
|
||||
const updateCacheConfigLocal = (updates: Partial<EditorCacheConfig>) => {
|
||||
setCacheConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
// Save all changes
|
||||
const handleSave = async () => {
|
||||
if (hasInvalidProviderBackedDimension) {
|
||||
toast.error(
|
||||
"Provider-backed semantic cache requires the embedding model's real dimension. Use a value greater than 1, or remove the provider to keep direct-only mode.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = cacheConfigSchema.safeParse(normalizeCacheConfigForSave(cacheConfig));
|
||||
if (!parseResult.success) {
|
||||
const firstIssue = parseResult.error.issues[0]?.message ?? "Semantic cache configuration is invalid.";
|
||||
toast.error(firstIssue);
|
||||
return;
|
||||
}
|
||||
|
||||
const savedConfig = parseResult.data as CacheConfig;
|
||||
|
||||
try {
|
||||
if (semanticCachePlugin) {
|
||||
// Update existing plugin
|
||||
await updatePlugin({
|
||||
name: SEMANTIC_CACHE_PLUGIN,
|
||||
data: { enabled: originalCacheEnabled, config: savedConfig },
|
||||
}).unwrap();
|
||||
} else {
|
||||
// Create new plugin
|
||||
await createPlugin({
|
||||
name: SEMANTIC_CACHE_PLUGIN,
|
||||
enabled: originalCacheEnabled,
|
||||
config: savedConfig,
|
||||
path: "",
|
||||
}).unwrap();
|
||||
}
|
||||
toast.success("Plugin configuration updated successfully");
|
||||
// Update server state to match current state
|
||||
const normalizedConfig = toEditorCacheConfig(savedConfig);
|
||||
setCacheConfig(normalizedConfig);
|
||||
setServerCacheConfig(normalizedConfig);
|
||||
setServerCacheEnabled(originalCacheEnabled);
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
toast.error(`Failed to update plugin configuration: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-muted-foreground">Loading plugins configuration...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Semantic Cache Toggle */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<label htmlFor="enable-caching" className="text-sm font-medium">
|
||||
Enable Semantic Caching
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Enable semantic caching for requests. Send <b>x-bf-cache-key</b> header with requests to use semantic caching.{" "}
|
||||
{!isVectorStoreEnabled && (
|
||||
<span className="text-destructive font-medium">Requires vector store to be configured and enabled in config.json.</span>
|
||||
)}
|
||||
{!providersLoading && providers?.length === 0 && (
|
||||
<span className="text-destructive font-medium"> Requires at least one provider to be configured.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="enable-caching"
|
||||
size="md"
|
||||
checked={originalCacheEnabled && isVectorStoreEnabled}
|
||||
disabled={!isVectorStoreEnabled || providersLoading || providers.length === 0}
|
||||
onCheckedChange={(checked) => {
|
||||
if (isVectorStoreEnabled) {
|
||||
handleSemanticCacheToggle(checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(isSemanticCacheEnabled || originalCacheEnabled) && (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isUpdating || isCreating || hasInvalidProviderBackedDimension}
|
||||
size="sm"
|
||||
>
|
||||
{isUpdating || isCreating ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Configuration (only show when enabled) */}
|
||||
{originalCacheEnabled &&
|
||||
isVectorStoreEnabled &&
|
||||
(providersLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Separator />
|
||||
{loadedDirectOnlyConfig && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
|
||||
This plugin was loaded in direct-only mode via <code>config.json</code>. The Web UI currently edits provider-backed
|
||||
semantic cache settings; keep using <code>config.json</code> if you want to stay in direct-only mode.
|
||||
</div>
|
||||
)}
|
||||
{hasInvalidProviderBackedDimension && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-900">
|
||||
You selected a provider while keeping <code>dimension: 1</code>. That is only valid for direct-only mode. Set the
|
||||
embedding model's real dimension before saving, or remove the provider to stay in direct-only mode.
|
||||
</div>
|
||||
)}
|
||||
{/* Provider and Model Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Provider and Model Settings</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider">Configured Providers</Label>
|
||||
<Select
|
||||
value={cacheConfig.provider}
|
||||
onValueChange={(value: ModelProviderName) => updateCacheConfigLocal({ provider: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers
|
||||
.filter((provider) => provider.name)
|
||||
.map((provider) => (
|
||||
<SelectItem key={provider.name} value={provider.name}>
|
||||
{getProviderLabel(provider.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embedding_model">Embedding Model*</Label>
|
||||
<Input
|
||||
id="embedding_model"
|
||||
placeholder="text-embedding-3-small"
|
||||
value={cacheConfig.embedding_model ?? ""}
|
||||
onChange={(e) => updateCacheConfigLocal({ embedding_model: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Cache Settings</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ttl">TTL (seconds)</Label>
|
||||
<Input
|
||||
id="ttl"
|
||||
type="number"
|
||||
min="1"
|
||||
value={cacheConfig.ttl_seconds === undefined || Number.isNaN(cacheConfig.ttl_seconds) ? "" : cacheConfig.ttl_seconds}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
updateCacheConfigLocal({ ttl_seconds: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
updateCacheConfigLocal({ ttl_seconds: parsed });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="threshold">Similarity Threshold</Label>
|
||||
<Input
|
||||
id="threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={cacheConfig.threshold === undefined || Number.isNaN(cacheConfig.threshold) ? "" : cacheConfig.threshold}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
updateCacheConfigLocal({ threshold: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = parseFloat(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
updateCacheConfigLocal({ threshold: parsed });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension">Dimension</Label>
|
||||
<Input
|
||||
id="dimension"
|
||||
type="number"
|
||||
min="1"
|
||||
value={cacheConfig.dimension === undefined || Number.isNaN(cacheConfig.dimension) ? "" : cacheConfig.dimension}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
updateCacheConfigLocal({ dimension: undefined });
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
updateCacheConfigLocal({ dimension: parsed });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
API keys for the embedding provider will be inherited from the main provider configuration. The semantic cache will use
|
||||
the configured provider's keys automatically. <b>Updates in keys will be reflected on Bifrost restart.</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Conversation Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Conversation Settings</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conversation_history_threshold">Conversation History Threshold</Label>
|
||||
<Input
|
||||
id="conversation_history_threshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={cacheConfig.conversation_history_threshold || 3}
|
||||
onChange={(e) => updateCacheConfigLocal({ conversation_history_threshold: parseInt(e.target.value) || 3 })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Skip caching for conversations with more than this number of messages (prevents false positives)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-fit items-center justify-between space-x-2 rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Exclude System Prompt</Label>
|
||||
<p className="text-muted-foreground text-xs">Exclude system messages from cache key generation</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cacheConfig.exclude_system_prompt || false}
|
||||
onCheckedChange={(checked) => updateCacheConfigLocal({ exclude_system_prompt: checked })}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Behavior */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Cache Behavior</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Cache by Model</Label>
|
||||
<p className="text-muted-foreground text-xs">Include model name in cache key</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cacheConfig.cache_by_model}
|
||||
onCheckedChange={(checked) => updateCacheConfigLocal({ cache_by_model: checked })}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Cache by Provider</Label>
|
||||
<p className="text-muted-foreground text-xs">Include provider name in cache key</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cacheConfig.cache_by_provider}
|
||||
onCheckedChange={(checked) => updateCacheConfigLocal({ cache_by_provider: checked })}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Notes</Label>
|
||||
<ul className="text-muted-foreground list-inside list-disc text-xs">
|
||||
<li>
|
||||
You can pass <b>x-bf-cache-ttl</b> header with requests to use request-specific TTL.
|
||||
</li>
|
||||
<li>
|
||||
You can pass <b>x-bf-cache-threshold</b> header with requests to use request-specific similarity threshold.
|
||||
</li>
|
||||
<li>
|
||||
You can pass <b>x-bf-cache-type</b> header with "direct" or "semantic" to control cache behavior.
|
||||
</li>
|
||||
<li>
|
||||
You can pass <b>x-bf-cache-no-store</b> header with "true" to disable response caching.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
ui/app/workspace/config/views/pricingConfigView.tsx
Normal file
164
ui/app/workspace/config/views/pricingConfigView.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getErrorMessage, useForcePricingSyncMutation, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PricingFormData {
|
||||
pricing_datasheet_url: string;
|
||||
pricing_sync_interval_hours: number;
|
||||
}
|
||||
|
||||
export default function PricingConfigView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.framework_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [forcePricingSync, { isLoading: isForceSyncing }] = useForcePricingSyncMutation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
watch,
|
||||
} = useForm<PricingFormData>({
|
||||
defaultValues: {
|
||||
pricing_datasheet_url: "",
|
||||
pricing_sync_interval_hours: 24,
|
||||
},
|
||||
});
|
||||
|
||||
const formValues = watch();
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
reset({
|
||||
pricing_datasheet_url: config.pricing_url || "",
|
||||
pricing_sync_interval_hours: Math.round(config.pricing_sync_interval / 3600) || 24,
|
||||
});
|
||||
}
|
||||
}, [config, bifrostConfig, reset]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config || !isDirty) return false;
|
||||
const serverUrl = config.pricing_url || "";
|
||||
const serverInterval = Math.round(config.pricing_sync_interval / 3600);
|
||||
return formValues.pricing_datasheet_url !== serverUrl || formValues.pricing_sync_interval_hours !== serverInterval;
|
||||
}, [config, formValues, isDirty]);
|
||||
|
||||
const onSubmit = async (data: PricingFormData) => {
|
||||
try {
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig!,
|
||||
framework_config: {
|
||||
...config,
|
||||
id: bifrostConfig?.framework_config.id || 0,
|
||||
pricing_url: data.pricing_datasheet_url,
|
||||
pricing_sync_interval: data.pricing_sync_interval_hours * 3600,
|
||||
},
|
||||
}).unwrap();
|
||||
toast.success("Pricing configuration updated successfully.");
|
||||
reset(data);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceSync = async () => {
|
||||
try {
|
||||
await forcePricingSync().unwrap();
|
||||
toast.success("Pricing sync triggered successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl space-y-4" data-testid="pricing-config-view">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Pricing Configuration</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure custom pricing datasheet and sync intervals.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Pricing Datasheet URL */}
|
||||
<div className="space-y-2 rounded-sm border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="pricing-datasheet-url">Pricing Datasheet URL</Label>
|
||||
<p className="text-muted-foreground text-sm">URL to a custom pricing datasheet. Leave empty to use default pricing.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="pricing-datasheet-url"
|
||||
type="text"
|
||||
placeholder="https://example.com/pricing.json"
|
||||
data-testid="pricing-datasheet-url-input"
|
||||
{...register("pricing_datasheet_url", {
|
||||
pattern: {
|
||||
value: /^(https?:\/\/)?((localhost|(\d{1,3}\.){3}\d{1,3})(:\d+)?|([\da-z\.-]+)\.([a-z\.]{2,6}))([\/\w \.-]*)*\/?$/,
|
||||
message: "Please enter a valid URL.",
|
||||
},
|
||||
validate: {
|
||||
checkIfHttp: (value) => {
|
||||
if (!value) return true; // Allow empty
|
||||
return value.startsWith("http://") || value.startsWith("https://") || "URL must start with http:// or https://";
|
||||
},
|
||||
},
|
||||
})}
|
||||
className={errors.pricing_datasheet_url ? "border-destructive" : ""}
|
||||
/>
|
||||
{errors.pricing_datasheet_url && <p className="text-destructive text-sm">{errors.pricing_datasheet_url.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Pricing Sync Interval */}
|
||||
<div className="space-y-2 rounded-sm border p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="pricing-sync-interval">Pricing Sync Interval (hours)</Label>
|
||||
<p className="text-muted-foreground text-sm">How often to sync pricing data from the datasheet URL.</p>
|
||||
</div>
|
||||
<Input
|
||||
id="pricing-sync-interval"
|
||||
type="number"
|
||||
className={errors.pricing_sync_interval_hours ? "border-destructive" : ""}
|
||||
{...register("pricing_sync_interval_hours", {
|
||||
required: "Pricing sync interval is required",
|
||||
min: {
|
||||
value: 1,
|
||||
message: "Sync interval must be at least 1 hour",
|
||||
},
|
||||
max: {
|
||||
value: 8760,
|
||||
message: "Sync interval cannot exceed 8760 hours (1 year)",
|
||||
},
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
{errors.pricing_sync_interval_hours && (
|
||||
<p className="text-destructive text-sm">{errors.pricing_sync_interval_hours.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={handleForceSync}
|
||||
disabled={isForceSyncing || !hasSettingsUpdateAccess}
|
||||
data-testid="pricing-force-sync-btn"
|
||||
>
|
||||
{isForceSyncing ? "Syncing..." : "Force Sync Now"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess} data-testid="pricing-save-btn">
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
ui/app/workspace/config/views/proxyView.tsx
Normal file
372
ui/app/workspace/config/views/proxyView.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { IS_ENTERPRISE } from "@/lib/constants/config";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateProxyConfigMutation } from "@/lib/store";
|
||||
import { DefaultGlobalProxyConfig, GlobalProxyConfig } from "@/lib/types/config";
|
||||
import { globalProxyConfigSchema } from "@/lib/types/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ProxyView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const proxyConfig = bifrostConfig?.proxy_config;
|
||||
const [updateProxyConfig, { isLoading }] = useUpdateProxyConfigMutation();
|
||||
|
||||
const form = useForm<GlobalProxyConfig>({
|
||||
resolver: zodResolver(globalProxyConfigSchema),
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
defaultValues: DefaultGlobalProxyConfig,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (proxyConfig) {
|
||||
form.reset({
|
||||
...DefaultGlobalProxyConfig,
|
||||
...proxyConfig,
|
||||
});
|
||||
}
|
||||
}, [proxyConfig, form]);
|
||||
|
||||
const watchedEnabled = form.watch("enabled");
|
||||
const watchedType = form.watch("type");
|
||||
|
||||
const onSubmit = async (data: GlobalProxyConfig) => {
|
||||
try {
|
||||
await updateProxyConfig(data).unwrap();
|
||||
toast.success("Proxy configuration updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const isTypeUnsupported = watchedType === "socks5" || watchedType === "tcp";
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Proxy Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure global proxy settings for outbound requests.</p>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!hasSettingsUpdateAccess} className="space-y-4">
|
||||
{/* Enable Proxy */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-sm font-medium">Enable Proxy</FormLabel>
|
||||
<p className="text-muted-foreground text-sm">Enable global proxy for outbound HTTP requests.</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Proxy Configuration Section */}
|
||||
<div className={cn("space-y-4 rounded-lg border p-4 transition-opacity", !watchedEnabled && "pointer-events-none opacity-50")}>
|
||||
<h3 className="text-lg font-medium">Proxy Configuration</h3>
|
||||
|
||||
{/* Proxy Type */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Proxy Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value} disabled={!watchedEnabled}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP / HTTPS</SelectItem>
|
||||
<SelectItem value="socks5" disabled>
|
||||
SOCKS5{" "}
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
Coming soon
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
<SelectItem value="tcp" disabled>
|
||||
TCP{" "}
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
Coming soon
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Select the proxy protocol type. Currently only HTTP proxy is supported.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isTypeUnsupported && watchedEnabled && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{watchedType.toUpperCase()} proxy is not yet supported. Please use HTTP proxy.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Proxy URL */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Proxy URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://proxy.example.com:8080" disabled={!watchedEnabled} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Full URL of the proxy server including protocol and port.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Authentication Section */}
|
||||
<div className="bg-muted/20 space-y-4 rounded-md border p-4">
|
||||
<h4 className="text-sm font-medium">Authentication (Optional)</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Proxy username" disabled={!watchedEnabled} {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Proxy password"
|
||||
disabled={!watchedEnabled}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<div className="bg-muted/20 space-y-4 rounded-md border p-4">
|
||||
<h4 className="text-sm font-medium">Advanced Settings</h4>
|
||||
|
||||
{/* No Proxy */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="no_proxy"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>No Proxy Hosts</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="localhost, 127.0.0.1, .internal.example.com"
|
||||
className="h-20"
|
||||
disabled={!watchedEnabled}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list of hosts that should bypass the proxy.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Timeout */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connection Timeout (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={300}
|
||||
placeholder="30"
|
||||
className="w-32"
|
||||
disabled={!watchedEnabled}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.target.value !== "" ? parseInt(e.target.value, 10) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Timeout for establishing proxy connections. 0 means no timeout. Default is 60 seconds.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* CA Certificate */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ca_cert_pem"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CA Certificate (PEM) (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
className="font-mono text-xs"
|
||||
rows={6}
|
||||
disabled={!watchedEnabled}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
PEM-encoded CA certificate to trust for TLS connections through SSL-intercepting proxies.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Skip TLS Verify */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-sm font-medium">Skip TLS Verification</FormLabel>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Disable TLS certificate verification for HTTPS proxies. Not recommended for production.
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="skip_tls_verify"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} disabled={!watchedEnabled} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entity Enablement Section */}
|
||||
<div className={cn("space-y-4 rounded-lg border p-4 transition-opacity", !watchedEnabled && "pointer-events-none opacity-50")}>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-medium">Enable Proxy For</h3>
|
||||
<p className="text-muted-foreground text-sm">Select which components should use the proxy for outbound requests.</p>
|
||||
</div>
|
||||
|
||||
{/* SCIM - Enterprise only */}
|
||||
{IS_ENTERPRISE && (
|
||||
<div className="flex items-center justify-between rounded-md border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-sm font-medium">SCIM</FormLabel>
|
||||
<Badge variant="secondary">Enterprise</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">Use proxy for SCIM directory sync requests.</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_for_scim"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} disabled={!watchedEnabled} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inference - Coming Soon */}
|
||||
<div className="flex items-center justify-between rounded-md border p-4 opacity-60">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-sm font-medium">Inference</FormLabel>
|
||||
<Badge variant="outline">Coming soon</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">Use proxy for LLM inference requests to model providers.</p>
|
||||
</div>
|
||||
<Switch disabled checked={false} />
|
||||
</div>
|
||||
|
||||
{/* API - Coming Soon */}
|
||||
<div className="flex items-center justify-between rounded-md border p-4 opacity-60">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-sm font-medium">API</FormLabel>
|
||||
<Badge variant="outline">Coming soon</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">Use proxy for external API calls and webhooks.</p>
|
||||
</div>
|
||||
<Switch disabled checked={false} />
|
||||
</div>
|
||||
|
||||
{!IS_ENTERPRISE && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>SCIM proxy support is available in Bifrost Enterprise.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={!hasSettingsUpdateAccess ? 0 : undefined}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.isDirty || !form.formState.isValid || isLoading || !hasSettingsUpdateAccess}
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!hasSettingsUpdateAccess && <TooltipContent>You don't have permission to update settings</TooltipContent>}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
421
ui/app/workspace/config/views/securityView.tsx
Normal file
421
ui/app/workspace/config/views/securityView.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EnvVarInput } from "@/components/ui/envVarInput";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { IS_ENTERPRISE } from "@/lib/constants/config";
|
||||
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
|
||||
import { AuthConfig, CoreConfig, DefaultCoreConfig } from "@/lib/types/config";
|
||||
import { EnvVar } from "@/lib/types/schemas";
|
||||
import { parseArrayFromText } from "@/lib/utils/array";
|
||||
import { validateOrigins } from "@/lib/utils/validation";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function SecurityView() {
|
||||
const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update);
|
||||
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
|
||||
const config = bifrostConfig?.client_config;
|
||||
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
|
||||
const [localConfig, setLocalConfig] = useState<CoreConfig>(DefaultCoreConfig);
|
||||
const hideAuthDashboard = IS_ENTERPRISE;
|
||||
|
||||
const [localValues, setLocalValues] = useState<{
|
||||
allowed_origins: string;
|
||||
allowed_headers: string;
|
||||
required_headers: string;
|
||||
whitelisted_routes: string;
|
||||
}>({
|
||||
allowed_origins: "",
|
||||
allowed_headers: "",
|
||||
required_headers: "",
|
||||
whitelisted_routes: "",
|
||||
});
|
||||
|
||||
const [authConfig, setAuthConfig] = useState<AuthConfig>({
|
||||
admin_username: { value: "", env_var: "", from_env: false },
|
||||
admin_password: { value: "", env_var: "", from_env: false },
|
||||
is_enabled: false,
|
||||
disable_auth_on_inference: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bifrostConfig && config) {
|
||||
setLocalConfig(config);
|
||||
setLocalValues({
|
||||
allowed_origins: config?.allowed_origins?.join(", ") || "",
|
||||
allowed_headers: config?.allowed_headers?.join(", ") || "",
|
||||
required_headers: config?.required_headers?.join(", ") || "",
|
||||
whitelisted_routes: config?.whitelisted_routes?.join(", ") || "",
|
||||
});
|
||||
}
|
||||
if (bifrostConfig?.auth_config) {
|
||||
setAuthConfig(bifrostConfig.auth_config);
|
||||
}
|
||||
}, [config, bifrostConfig]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!config) return false;
|
||||
const localOrigins = localConfig.allowed_origins?.slice().sort().join(",");
|
||||
const serverOrigins = config.allowed_origins?.slice().sort().join(",");
|
||||
const originsChanged = localOrigins !== serverOrigins;
|
||||
|
||||
const localHeaders = localConfig.allowed_headers?.slice().sort().join(",");
|
||||
const serverHeaders = config.allowed_headers?.slice().sort().join(",");
|
||||
const headersChanged = localHeaders !== serverHeaders;
|
||||
|
||||
const usernameChanged =
|
||||
authConfig.admin_username?.value !== bifrostConfig?.auth_config?.admin_username?.value ||
|
||||
authConfig.admin_username?.env_var !== bifrostConfig?.auth_config?.admin_username?.env_var ||
|
||||
authConfig.admin_username?.from_env !== bifrostConfig?.auth_config?.admin_username?.from_env;
|
||||
const passwordChanged =
|
||||
authConfig.admin_password?.value !== bifrostConfig?.auth_config?.admin_password?.value ||
|
||||
authConfig.admin_password?.env_var !== bifrostConfig?.auth_config?.admin_password?.env_var ||
|
||||
authConfig.admin_password?.from_env !== bifrostConfig?.auth_config?.admin_password?.from_env;
|
||||
const authChanged =
|
||||
authConfig.is_enabled !== bifrostConfig?.auth_config?.is_enabled ||
|
||||
usernameChanged ||
|
||||
passwordChanged ||
|
||||
authConfig.disable_auth_on_inference !== bifrostConfig?.auth_config?.disable_auth_on_inference;
|
||||
|
||||
const localRequired = localConfig.required_headers?.slice().sort().join(",");
|
||||
const serverRequired = config.required_headers?.slice().sort().join(",");
|
||||
const requiredChanged = localRequired !== serverRequired;
|
||||
|
||||
const localWhitelistedRoutes = localConfig.whitelisted_routes?.slice().sort().join(",");
|
||||
const serverWhitelistedRoutes = config.whitelisted_routes?.slice().sort().join(",");
|
||||
const whitelistedRoutesChanged = localWhitelistedRoutes !== serverWhitelistedRoutes;
|
||||
|
||||
const enforceAuthOnInferenceChanged = localConfig.enforce_auth_on_inference !== config.enforce_auth_on_inference;
|
||||
const allowDirectKeysChanged = localConfig.allow_direct_keys !== config.allow_direct_keys;
|
||||
|
||||
return (
|
||||
originsChanged ||
|
||||
headersChanged ||
|
||||
requiredChanged ||
|
||||
whitelistedRoutesChanged ||
|
||||
authChanged ||
|
||||
enforceAuthOnInferenceChanged ||
|
||||
allowDirectKeysChanged
|
||||
);
|
||||
}, [config, localConfig, authConfig, bifrostConfig]);
|
||||
|
||||
const needsRestart = useMemo(() => {
|
||||
if (!config) return false;
|
||||
|
||||
const localOrigins = localConfig.allowed_origins?.slice().sort().join(",");
|
||||
const serverOrigins = config.allowed_origins?.slice().sort().join(",");
|
||||
const originsChanged = localOrigins !== serverOrigins;
|
||||
|
||||
const localHeaders = localConfig.allowed_headers?.slice().sort().join(",");
|
||||
const serverHeaders = config.allowed_headers?.slice().sort().join(",");
|
||||
const headersChanged = localHeaders !== serverHeaders;
|
||||
|
||||
const enforceAuthOnInferenceChanged = localConfig.enforce_auth_on_inference !== config.enforce_auth_on_inference && IS_ENTERPRISE;
|
||||
|
||||
return originsChanged || headersChanged || enforceAuthOnInferenceChanged;
|
||||
}, [config, localConfig]);
|
||||
|
||||
const handleAllowedOriginsChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, allowed_origins: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, allowed_origins: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleAllowedHeadersChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, allowed_headers: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, allowed_headers: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleRequiredHeadersChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, required_headers: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, required_headers: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleWhitelistedRoutesChange = useCallback((value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, whitelisted_routes: value }));
|
||||
setLocalConfig((prev) => ({ ...prev, whitelisted_routes: parseArrayFromText(value) }));
|
||||
}, []);
|
||||
|
||||
const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean) => {
|
||||
setLocalConfig((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleAuthToggle = useCallback((checked: boolean) => {
|
||||
setAuthConfig((prev) => ({ ...prev, is_enabled: checked }));
|
||||
}, []);
|
||||
|
||||
const handleDisableAuthOnInferenceToggle = useCallback((checked: boolean) => {
|
||||
setAuthConfig((prev) => ({ ...prev, disable_auth_on_inference: checked }));
|
||||
}, []);
|
||||
|
||||
const handleAuthFieldChange = useCallback((field: "admin_username" | "admin_password", value: EnvVar) => {
|
||||
setAuthConfig((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const validation = validateOrigins(localConfig.allowed_origins);
|
||||
|
||||
if (!validation.isValid && localConfig.allowed_origins.length > 0) {
|
||||
toast.error(
|
||||
`Invalid origins: ${validation.invalidOrigins.join(", ")}. Origins must be valid URLs like https://example.com, wildcard patterns like https://*.example.com, or "*" to allow all origins`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const hasUsername = authConfig.admin_username?.value || authConfig.admin_username?.env_var;
|
||||
const hasPassword = authConfig.admin_password?.value || authConfig.admin_password?.env_var;
|
||||
await updateCoreConfig({
|
||||
...bifrostConfig!,
|
||||
client_config: localConfig,
|
||||
auth_config: authConfig.is_enabled && hasUsername && hasPassword ? authConfig : { ...authConfig, is_enabled: false },
|
||||
}).unwrap();
|
||||
toast.success("Security settings updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}, [bifrostConfig, localConfig, authConfig, updateCoreConfig]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Security Settings</h2>
|
||||
<p className="text-muted-foreground text-sm">Configure security and access control settings.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{authConfig.is_enabled && !authConfig.disable_auth_on_inference && (
|
||||
<Alert variant="default" className="border-blue-20">
|
||||
<Info className="h-4 w-4 text-blue-600" />
|
||||
<AlertDescription>
|
||||
You will need to use Basic Auth for all your inference calls (including MCP tool execution). You can disable it below. Check{" "}
|
||||
<Link to="/workspace/config/api-keys" className="text-md text-primary underline">
|
||||
API Keys
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{authConfig.is_enabled && authConfig.disable_auth_on_inference && (
|
||||
<Alert variant="default" className="border-blue-20">
|
||||
<Info className="h-4 w-4 text-blue-600" />
|
||||
<AlertDescription>
|
||||
Authentication is disabled for inference calls. Only dashboard, admin API and MCP tool execution calls require authentication.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Password Protect the Dashboard */}
|
||||
{!hideAuthDashboard && (
|
||||
<div>
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auth-enabled" className="text-sm font-medium">
|
||||
Password protect the dashboard <Badge variant="secondary">BETA</Badge>
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Set up authentication credentials to protect your Bifrost dashboard. Once configured, use the generated token for all
|
||||
admin API calls.
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="auth-enabled" checked={authConfig.is_enabled} onCheckedChange={handleAuthToggle} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-username">Username</Label>
|
||||
<EnvVarInput
|
||||
id="admin-username"
|
||||
type="text"
|
||||
placeholder="Enter admin username or env.VAR_NAME"
|
||||
value={authConfig.admin_username}
|
||||
disabled={!authConfig.is_enabled}
|
||||
onChange={(value) => handleAuthFieldChange("admin_username", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-password">Password</Label>
|
||||
<EnvVarInput
|
||||
id="admin-password"
|
||||
type="password"
|
||||
placeholder="Enter admin password or env.VAR_NAME"
|
||||
value={authConfig.admin_password}
|
||||
disabled={!authConfig.is_enabled}
|
||||
onChange={(value) => handleAuthFieldChange("admin_password", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="disable-auth-inference" className="text-sm font-medium">
|
||||
Disable authentication on inference calls
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
When enabled, inference API calls (chat completions, embeddings, etc.) will not require authentication. Dashboard and
|
||||
admin API calls will still require authentication.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-auth-inference"
|
||||
className="ml-5"
|
||||
checked={authConfig.disable_auth_on_inference ?? false}
|
||||
disabled={!authConfig.is_enabled}
|
||||
onCheckedChange={handleDisableAuthOnInferenceToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Enable Auth on Inference */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="enforce-auth-on-inference" className="text-sm font-medium">
|
||||
{IS_ENTERPRISE ? "Enable Auth on Inference" : "Enforce Virtual Keys on Inference"}
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{IS_ENTERPRISE
|
||||
? "Require authentication (virtual key, API key, or user token) for all inference endpoints."
|
||||
: "Require a virtual key for all inference requests."}{" "}
|
||||
See{" "}
|
||||
<a
|
||||
href="https://docs.getbifrost.ai/features/governance/virtual-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
data-testid="security-virtual-keys-docs-link"
|
||||
>
|
||||
documentation
|
||||
</a>{" "}
|
||||
for details.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enforce-auth-on-inference"
|
||||
data-testid="enforce-auth-on-inference-switch"
|
||||
checked={localConfig.enforce_auth_on_inference}
|
||||
onCheckedChange={(checked) => handleConfigChange("enforce_auth_on_inference", checked)}
|
||||
/>
|
||||
</div>
|
||||
{/* Allowed Origins */}
|
||||
{needsRestart && <RestartWarning />}
|
||||
{/* Allow Direct API Keys */}
|
||||
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="allow-direct-keys" className="text-sm font-medium">
|
||||
Allow Direct API Keys
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Allow API keys to be passed directly in request headers (<b>Authorization</b>, <b>x-api-key</b>, or <b>x-goog-api-key</b>).
|
||||
Bifrost will directly use the key.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="allow-direct-keys"
|
||||
checked={localConfig.allow_direct_keys}
|
||||
onCheckedChange={(checked) => handleConfigChange("allow_direct_keys", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="allowed-origins" className="text-sm font-medium">
|
||||
Allowed Origins
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Comma-separated list of allowed origins for CORS and WebSocket connections. Localhost origins are always allowed. Each
|
||||
origin must be a complete URL with protocol (e.g., https://app.example.com, http://10.0.0.100:3000). Wildcards are supported
|
||||
for subdomains (e.g., https://*.example.com) or use "*" to allow all origins.
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="allowed-origins"
|
||||
className="h-24"
|
||||
placeholder="https://app.example.com, https://*.example.com, *"
|
||||
value={localValues.allowed_origins}
|
||||
onChange={(e) => handleAllowedOriginsChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Allowed Headers */}
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="allowed-headers" className="text-sm font-medium">
|
||||
Allowed Headers
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">Comma-separated list of allowed headers for CORS.</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="allowed-headers"
|
||||
className="h-24"
|
||||
placeholder="X-Stainless-Timeout"
|
||||
value={localValues.allowed_headers}
|
||||
onChange={(e) => handleAllowedHeadersChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Required Headers */}
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="required-headers" className="text-sm font-medium">
|
||||
Required Headers
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Comma-separated list of headers that must be present on every request. Requests missing any of these headers will be
|
||||
rejected with a 400 error. Header names are case-insensitive.
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="required-headers"
|
||||
data-testid="required-headers-textarea"
|
||||
className="h-24"
|
||||
placeholder="X-Tenant-ID, X-Custom-Header"
|
||||
value={localValues.required_headers}
|
||||
onChange={(e) => handleRequiredHeadersChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Whitelisted Routes */}
|
||||
<div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<label htmlFor="whitelisted-routes" className="text-sm font-medium">
|
||||
Whitelisted Routes
|
||||
</label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Comma-separated list of routes that bypass the auth middleware. Requests to these routes will not require authentication.
|
||||
System routes like <b>/health</b>, <b>/api/session/login</b>, and <b>/api/session/is-auth-enabled</b> are always whitelisted
|
||||
regardless of this setting.
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="whitelisted-routes"
|
||||
data-testid="whitelisted-routes-textarea"
|
||||
className="h-24"
|
||||
placeholder="/api/custom-webhook, /api/public-endpoint"
|
||||
value={localValues.whitelisted_routes}
|
||||
onChange={(e) => handleWhitelistedRoutesChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || isLoading || !hasSettingsUpdateAccess}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RestartWarning = () => {
|
||||
return (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>Need to restart Bifrost to apply changes.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
21
ui/app/workspace/custom-pricing/layout.tsx
Normal file
21
ui/app/workspace/custom-pricing/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router";
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import CustomPricingPage from "./page";
|
||||
|
||||
function CustomPricingLayout({ children }: { children: React.ReactNode }) {
|
||||
const hasSettingsAccess = useRbac(RbacResource.Settings, RbacOperation.View);
|
||||
if (!hasSettingsAccess) {
|
||||
return <NoPermissionView entity="custom pricing" />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const childMatches = useChildMatches();
|
||||
return <CustomPricingLayout>{childMatches.length === 0 ? <CustomPricingPage /> : <Outlet />}</CustomPricingLayout>;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/workspace/custom-pricing")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
6
ui/app/workspace/custom-pricing/overrides/layout.tsx
Normal file
6
ui/app/workspace/custom-pricing/overrides/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ScopedPricingOverridesPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/custom-pricing/overrides")({
|
||||
component: ScopedPricingOverridesPage,
|
||||
});
|
||||
9
ui/app/workspace/custom-pricing/overrides/page.tsx
Normal file
9
ui/app/workspace/custom-pricing/overrides/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import ScopedPricingOverridesView from "@/app/workspace/custom-pricing/overrides/scopedPricingOverridesView";
|
||||
|
||||
export default function ScopedPricingOverridesPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<ScopedPricingOverridesView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Plus, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { FieldErrors, PricingFieldKey } from "./pricingOverrideSheet";
|
||||
import { PRICING_FIELDS } from "./pricingOverrideSheet";
|
||||
|
||||
type GroupKey = "chat" | "embedding" | "rerank" | "audio" | "image" | "video" | "ocr";
|
||||
|
||||
const PRICING_GROUPS: { key: GroupKey; label: string }[] = [
|
||||
{ key: "chat", label: "Chat / Text / Responses" },
|
||||
{ key: "embedding", label: "Embedding" },
|
||||
{ key: "rerank", label: "Rerank" },
|
||||
{ key: "audio", label: "Audio" },
|
||||
{ key: "image", label: "Image" },
|
||||
{ key: "video", label: "Video" },
|
||||
{ key: "ocr", label: "OCR" },
|
||||
];
|
||||
|
||||
const REQUEST_TYPE_TO_CATEGORY: Record<string, GroupKey> = {
|
||||
chat_completion: "chat",
|
||||
text_completion: "chat",
|
||||
responses: "chat",
|
||||
embedding: "embedding",
|
||||
rerank: "rerank",
|
||||
speech: "audio",
|
||||
transcription: "audio",
|
||||
image_generation: "image",
|
||||
image_variation: "image",
|
||||
image_edit: "image",
|
||||
video_generation: "video",
|
||||
video_remix: "video",
|
||||
ocr: "ocr",
|
||||
};
|
||||
|
||||
interface PricingFieldSelectorProps {
|
||||
values: Partial<Record<PricingFieldKey, string>>;
|
||||
errors: FieldErrors;
|
||||
selectedRequestTypes?: string[];
|
||||
onChange: (key: PricingFieldKey, value: string) => void;
|
||||
onFieldInteraction?: () => void;
|
||||
}
|
||||
|
||||
export function PricingFieldSelector({ values, errors, selectedRequestTypes, onChange, onFieldInteraction }: PricingFieldSelectorProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [openGroups, setOpenGroups] = useState<Set<GroupKey>>(new Set(["chat"]));
|
||||
|
||||
const [activeFields, setActiveFields] = useState<Set<PricingFieldKey>>(
|
||||
() => new Set(PRICING_FIELDS.filter((f) => values[f.key] != null && values[f.key]!.trim() !== "").map((f) => f.key)),
|
||||
);
|
||||
|
||||
// Sync active fields to exactly the set of keys that have non-empty values.
|
||||
// This handles both loading new overrides (adds keys) and clearing the patch (removes stale keys).
|
||||
useEffect(() => {
|
||||
setActiveFields(new Set(PRICING_FIELDS.filter((f) => values[f.key] != null && values[f.key]!.trim() !== "").map((f) => f.key)));
|
||||
}, [values]);
|
||||
|
||||
// Derive active categories from selected request types
|
||||
const activeCategories = useMemo<Set<GroupKey> | null>(() => {
|
||||
if (!selectedRequestTypes || selectedRequestTypes.length === 0) return null;
|
||||
const cats = new Set<GroupKey>();
|
||||
for (const rt of selectedRequestTypes) {
|
||||
const cat = REQUEST_TYPE_TO_CATEGORY[rt];
|
||||
if (cat) cats.add(cat);
|
||||
}
|
||||
return cats.size > 0 ? cats : null;
|
||||
}, [selectedRequestTypes]);
|
||||
|
||||
const trimmedSearch = search.trim().toLowerCase();
|
||||
const isSearching = trimmedSearch.length > 0;
|
||||
|
||||
const filteredFields = useMemo(() => {
|
||||
if (!isSearching) return null;
|
||||
return PRICING_FIELDS.filter((f) => f.label.toLowerCase().includes(trimmedSearch) || f.key.toLowerCase().includes(trimmedSearch));
|
||||
}, [isSearching, trimmedSearch]);
|
||||
|
||||
// Fields visible per group when not searching, respecting activeCategories filter
|
||||
const visibleGroupedFields = useMemo(
|
||||
() =>
|
||||
PRICING_GROUPS.map((group) => {
|
||||
const fields = PRICING_FIELDS.filter((f) => {
|
||||
if (f.group !== group.key) return false;
|
||||
if (activeCategories === null) return true;
|
||||
return (f.requestTypeGroups as readonly string[]).some((rg) => activeCategories.has(rg as GroupKey));
|
||||
});
|
||||
return { ...group, fields };
|
||||
}).filter((g) => g.fields.length > 0),
|
||||
[activeCategories],
|
||||
);
|
||||
|
||||
const toggleGroup = (key: GroupKey) => {
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const activateField = (key: PricingFieldKey) => {
|
||||
setActiveFields((prev) => new Set([...prev, key]));
|
||||
};
|
||||
|
||||
const deactivateField = (key: PricingFieldKey) => {
|
||||
setActiveFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
onFieldInteraction?.();
|
||||
onChange(key, "");
|
||||
};
|
||||
|
||||
const handleInputChange = (key: PricingFieldKey, value: string) => {
|
||||
onFieldInteraction?.();
|
||||
onChange(key, value);
|
||||
};
|
||||
|
||||
const renderFieldRow = (field: { key: PricingFieldKey; label: string }) => {
|
||||
const isActive = activeFields.has(field.key);
|
||||
const hasValue = values[field.key]?.trim();
|
||||
const error = errors[field.key];
|
||||
|
||||
if (!isActive) {
|
||||
return (
|
||||
<button
|
||||
key={field.key}
|
||||
type="button"
|
||||
className="hover:bg-muted flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors"
|
||||
onClick={() => activateField(field.key)}
|
||||
data-testid={`pricing-field-activate-${field.key}`}
|
||||
>
|
||||
<Plus className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground">{field.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} className="rounded-sm px-1 py-1.5">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="flex-1 text-sm font-medium">{field.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground rounded-sm p-0.5 transition-colors"
|
||||
onClick={() => deactivateField(field.key)}
|
||||
data-testid={`pricing-field-deactivate-${field.key}`}
|
||||
title="Remove field"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
data-testid={`pricing-override-field-input-${field.key}`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className={cn("h-8", hasValue && "ring-primary/40 ring-1")}
|
||||
value={values[field.key] ?? ""}
|
||||
onChange={(e) => handleInputChange(field.key, e.target.value)}
|
||||
placeholder="0.0"
|
||||
/>
|
||||
{error && <p className="text-destructive mt-1 text-xs">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Search all pricing fields..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9"
|
||||
data-testid="pricing-field-search"
|
||||
/>
|
||||
|
||||
<div className="rounded-md border">
|
||||
{isSearching ? (
|
||||
<div className="space-y-0.5 p-2">
|
||||
{filteredFields!.length === 0 ? (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">No fields match “{search}”</div>
|
||||
) : (
|
||||
filteredFields!.map((field) => renderFieldRow(field))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{visibleGroupedFields.length === 0 ? (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">No pricing fields for the selected request types</div>
|
||||
) : (
|
||||
visibleGroupedFields.map((group) => {
|
||||
const isOpen = openGroups.has(group.key);
|
||||
const valueCount = group.fields.filter((f) => values[f.key]?.trim()).length;
|
||||
|
||||
return (
|
||||
<div key={group.key}>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted/50 flex w-full items-center justify-between px-3 py-2.5 text-sm font-medium transition-colors"
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
data-testid={`pricing-group-toggle-${group.key}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{group.label}
|
||||
{valueCount > 0 && (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
{valueCount}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("text-muted-foreground h-4 w-4 transition-transform duration-200", isOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="bg-muted/20 space-y-0.5 border-t px-2 pt-1 pb-2">
|
||||
{group.fields.map((field) => renderFieldRow(field))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1057
ui/app/workspace/custom-pricing/overrides/pricingOverrideSheet.tsx
Normal file
1057
ui/app/workspace/custom-pricing/overrides/pricingOverrideSheet.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowUpRight, SlidersHorizontal } from "lucide-react";
|
||||
|
||||
const PRICING_OVERRIDES_DOCS_URL = "https://docs.getbifrost.ai/features/governance/custom-pricing";
|
||||
|
||||
interface PricingOverridesEmptyStateProps {
|
||||
onCreateClick: () => void;
|
||||
}
|
||||
|
||||
export function PricingOverridesEmptyState({ onCreateClick }: PricingOverridesEmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center"
|
||||
data-testid="pricing-overrides-empty-state"
|
||||
>
|
||||
<div className="text-muted-foreground">
|
||||
<SlidersHorizontal className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-muted-foreground text-xl font-medium">Pricing overrides customize cost tracking per scope</h1>
|
||||
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
|
||||
Define custom per-token prices for specific providers, keys, or virtual keys to accurately reflect your negotiated rates.
|
||||
</div>
|
||||
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Read more about pricing overrides (opens in new tab)"
|
||||
data-testid="pricing-overrides-button-read-more"
|
||||
onClick={() => {
|
||||
window.open(`${PRICING_OVERRIDES_DOCS_URL}?utm_source=bfd`, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
|
||||
</Button>
|
||||
<Button aria-label="Create your first pricing override" data-testid="pricing-override-create-btn" onClick={onCreateClick}>
|
||||
Create Override
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alertDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useDebouncedValue } from "@/hooks/useDebounce";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import { getProviderLabel } from "@/lib/constants/logs";
|
||||
import {
|
||||
getErrorMessage,
|
||||
useDeletePricingOverrideMutation,
|
||||
useGetPricingOverridesQuery,
|
||||
useGetProvidersQuery,
|
||||
useGetVirtualKeysQuery,
|
||||
} from "@/lib/store";
|
||||
import { useGetAllKeysQuery } from "@/lib/store/apis/providersApi";
|
||||
import { PricingOverride, PricingOverrideScopeKind } from "@/lib/types/governance";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import { ChevronLeft, ChevronRight, Edit, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import PricingOverrideSheet from "./pricingOverrideSheet";
|
||||
import { PricingOverridesEmptyState } from "./pricingOverridesEmptyState";
|
||||
|
||||
type ScopeFilter = "all" | PricingOverrideScopeKind;
|
||||
|
||||
function parseScopeKind(value: string | null): ScopeFilter {
|
||||
if (
|
||||
value === "global" ||
|
||||
value === "provider" ||
|
||||
value === "provider_key" ||
|
||||
value === "virtual_key" ||
|
||||
value === "virtual_key_provider" ||
|
||||
value === "virtual_key_provider_key"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
// Returns the top-level scope label: "Global" or the virtual key name.
|
||||
function scopeLabel(override: PricingOverride, _virtualKeyMap: Map<string, string>): string {
|
||||
const scopeKind = resolveScopeKind(override);
|
||||
if (override.virtual_key_id && scopeKind.startsWith("virtual_key")) {
|
||||
return "Virtual Key";
|
||||
}
|
||||
return "Global";
|
||||
}
|
||||
|
||||
// Returns the key label for the override, or "-" when no specific key is scoped.
|
||||
function keyLabel(override: PricingOverride, keyLabelMap: Map<string, string>): string {
|
||||
if (!override.provider_key_id) {
|
||||
if (!override.provider_id) return "-";
|
||||
return "All Keys";
|
||||
}
|
||||
return keyLabelMap.get(override.provider_key_id) || override.provider_key_id;
|
||||
}
|
||||
|
||||
// Returns the provider label for the override, or "-" if not applicable.
|
||||
function providerLabel(override: PricingOverride, providerMap: Map<string, string>, keyProviderMap: Map<string, string>): string {
|
||||
const scopeKind = resolveScopeKind(override);
|
||||
switch (scopeKind) {
|
||||
case "provider":
|
||||
case "virtual_key_provider":
|
||||
return providerMap.get(override.provider_id || "") || override.provider_id || "-";
|
||||
case "provider_key":
|
||||
case "virtual_key_provider_key": {
|
||||
const keyID = override.provider_key_id || "";
|
||||
return providerMap.get(keyProviderMap.get(keyID) || "") || keyProviderMap.get(keyID) || "-";
|
||||
}
|
||||
default:
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScopeKind(override: PricingOverride): PricingOverrideScopeKind {
|
||||
if (
|
||||
override.scope_kind === "global" ||
|
||||
override.scope_kind === "provider" ||
|
||||
override.scope_kind === "provider_key" ||
|
||||
override.scope_kind === "virtual_key" ||
|
||||
override.scope_kind === "virtual_key_provider" ||
|
||||
override.scope_kind === "virtual_key_provider_key"
|
||||
) {
|
||||
return override.scope_kind;
|
||||
}
|
||||
if (override.virtual_key_id) {
|
||||
if (override.provider_key_id) return "virtual_key_provider_key";
|
||||
if (override.provider_id) return "virtual_key_provider";
|
||||
return "virtual_key";
|
||||
}
|
||||
if (override.provider_key_id) return "provider_key";
|
||||
if (override.provider_id) return "provider";
|
||||
return "global";
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function ScopedPricingOverridesView() {
|
||||
const location = useLocation();
|
||||
const searchParams = useMemo(() => new URLSearchParams(location.searchStr), [location.searchStr]);
|
||||
|
||||
const [scopeKind, setScopeKind] = useState<ScopeFilter>(() => parseScopeKind(searchParams.get("scope_kind")));
|
||||
const [virtualKeyID, setVirtualKeyID] = useState(() => (searchParams.get("virtual_key_id") || "").trim());
|
||||
const [providerID, setProviderID] = useState(() => (searchParams.get("provider_id") || "").trim());
|
||||
const [providerKeyID, setProviderKeyID] = useState(() => (searchParams.get("provider_key_id") || "").trim());
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const debouncedSearch = useDebouncedValue(search, 300);
|
||||
|
||||
useEffect(() => {
|
||||
setScopeKind(parseScopeKind(searchParams.get("scope_kind")));
|
||||
setVirtualKeyID((searchParams.get("virtual_key_id") || "").trim());
|
||||
setProviderID((searchParams.get("provider_id") || "").trim());
|
||||
setProviderKeyID((searchParams.get("provider_key_id") || "").trim());
|
||||
}, [searchParams]);
|
||||
|
||||
// Reset to first page when filters or search change
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
}, [scopeKind, virtualKeyID, providerID, providerKeyID, debouncedSearch]);
|
||||
|
||||
const queryArgs = useMemo(
|
||||
() => ({
|
||||
scopeKind: scopeKind === "all" ? undefined : scopeKind,
|
||||
virtualKeyID: virtualKeyID || undefined,
|
||||
providerID: providerID || undefined,
|
||||
providerKeyID: providerKeyID || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
search: debouncedSearch || undefined,
|
||||
}),
|
||||
[scopeKind, virtualKeyID, providerID, providerKeyID, offset, debouncedSearch],
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = useGetPricingOverridesQuery(queryArgs);
|
||||
|
||||
// Snap offset back when total shrinks past current page
|
||||
const totalCount = data?.total_count ?? 0;
|
||||
useEffect(() => {
|
||||
if (offset < totalCount) return;
|
||||
setOffset(totalCount === 0 ? 0 : Math.floor((totalCount - 1) / PAGE_SIZE) * PAGE_SIZE);
|
||||
}, [totalCount, offset]);
|
||||
const { data: providersData } = useGetProvidersQuery();
|
||||
const { data: virtualKeysData } = useGetVirtualKeysQuery();
|
||||
const { data: allKeysData = [] } = useGetAllKeysQuery();
|
||||
const [deleteOverride, { isLoading: isDeleting }] = useDeletePricingOverrideMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error("Failed to load pricing overrides", { description: getErrorMessage(error) });
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [editingOverride, setEditingOverride] = useState<PricingOverride | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<PricingOverride | null>(null);
|
||||
|
||||
const rows = data?.pricing_overrides ?? [];
|
||||
const providers = useMemo(() => providersData ?? [], [providersData]);
|
||||
const virtualKeys = useMemo(() => virtualKeysData?.virtual_keys ?? [], [virtualKeysData]);
|
||||
|
||||
const providerMap = useMemo(() => new Map<string, string>(providers.map((provider) => [provider.name, provider.name])), [providers]);
|
||||
const providerKeyOptions = useMemo(
|
||||
() =>
|
||||
allKeysData.map((key) => ({
|
||||
id: key.key_id,
|
||||
label: key.name || key.key_id,
|
||||
providerName: key.provider,
|
||||
})),
|
||||
[allKeysData],
|
||||
);
|
||||
const providerKeyProviderMap = useMemo(
|
||||
() => new Map<string, string>(providerKeyOptions.map((key) => [key.id, key.providerName])),
|
||||
[providerKeyOptions],
|
||||
);
|
||||
const providerKeyLabelMap = useMemo(
|
||||
() => new Map<string, string>(providerKeyOptions.map((key) => [key.id, key.label])),
|
||||
[providerKeyOptions],
|
||||
);
|
||||
const virtualKeyMap = useMemo(() => new Map<string, string>(virtualKeys.map((vk) => [vk.id, vk.name])), [virtualKeys]);
|
||||
|
||||
const createScopeLock = useMemo(() => {
|
||||
if (scopeKind === "all") return undefined;
|
||||
return {
|
||||
scopeKind,
|
||||
virtualKeyID: virtualKeyID || undefined,
|
||||
providerID: providerID || undefined,
|
||||
providerKeyID: providerKeyID || undefined,
|
||||
label: `${scopeKind}${virtualKeyID || providerID || providerKeyID ? " (filtered)" : ""}`,
|
||||
};
|
||||
}, [scopeKind, virtualKeyID, providerID, providerKeyID]);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
setEditingOverride(null);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEditDrawer = (override: PricingOverride) => {
|
||||
setEditingOverride(override);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteOverride(deleteTarget.id).unwrap();
|
||||
toast.success("Pricing override deleted");
|
||||
setDeleteTarget(null);
|
||||
} catch (deleteError) {
|
||||
toast.error("Failed to delete pricing override", { description: getErrorMessage(deleteError) });
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters = debouncedSearch || scopeKind !== "all" || virtualKeyID || providerID || providerKeyID;
|
||||
|
||||
if (!isLoading && !error && totalCount === 0 && !hasActiveFilters) {
|
||||
return (
|
||||
<>
|
||||
<PricingOverridesEmptyState onCreateClick={openCreateDrawer} />
|
||||
<PricingOverrideSheet
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
editingOverride={editingOverride}
|
||||
scopeLock={createScopeLock}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Pricing Overrides</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Set custom rates for any model across global or virtual key scopes, optionally narrowed to a specific provider or key
|
||||
</p>
|
||||
</div>
|
||||
<Button data-testid="pricing-override-create-btn" onClick={openCreateDrawer} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">New Override</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
aria-label="Search pricing overrides by name"
|
||||
placeholder="Search by name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
data-testid="pricing-overrides-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-sm border">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-sm">Loading overrides...</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-sm text-red-500">Failed to load pricing overrides. Please try refreshing the page.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="font-semibold">Name</TableHead>
|
||||
<TableHead className="font-semibold">Scope</TableHead>
|
||||
<TableHead className="font-semibold">Provider</TableHead>
|
||||
<TableHead className="font-semibold">Key</TableHead>
|
||||
<TableHead className="font-semibold">Model</TableHead>
|
||||
<TableHead className="w-[100px] text-right font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
<span className="text-muted-foreground text-sm">No matching pricing overrides found.</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.id} className="hover:bg-muted/50 cursor-pointer transition-colors">
|
||||
<TableCell>{row.name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{scopeLabel(row, virtualKeyMap)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const name = providerLabel(row, providerMap, providerKeyProviderMap);
|
||||
if (name === "-") return <span className="text-muted-foreground text-sm">-</span>;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RenderProviderIcon provider={name as ProviderIconType} size="sm" className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm">{getProviderLabel(name)}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>{keyLabel(row, providerKeyLabelMap)}</TableCell>
|
||||
<TableCell>{row.pattern}</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
data-testid={`pricing-override-edit-btn-${row.id}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDrawer(row)}
|
||||
aria-label="Edit pricing override"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
data-testid={`pricing-override-delete-btn-${row.id}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
aria-label="Delete pricing override"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, totalCount)} of {totalCount}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
data-testid="pricing-overrides-pagination-prev-btn"
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset + PAGE_SIZE >= totalCount}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
data-testid="pricing-overrides-pagination-next-btn"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PricingOverrideSheet
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
editingOverride={editingOverride}
|
||||
scopeLock={createScopeLock}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => (!open ? setDeleteTarget(null) : undefined)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Pricing Override</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-testid="pricing-override-delete-cancel-btn" disabled={isDeleting}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="pricing-override-delete-confirm-btn"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteConfirm();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user