first commit
This commit is contained in:
43
ui/.gitignore
vendored
Normal file
43
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# build output
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# auto-generated TanStack Router route tree
|
||||
/app/routeTree.gen.ts
|
||||
11
ui/.oxfmtrc.json
Normal file
11
ui/.oxfmtrc.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 140,
|
||||
"singleQuote": false,
|
||||
"semi": true,
|
||||
"useTabs": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"sortTailwindcss": true,
|
||||
"ignorePatterns": ["out/**", "node_modules/**"]
|
||||
}
|
||||
17
ui/.oxlintrc.json
Normal file
17
ui/.oxlintrc.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "import", "react"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2024": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"import/no-cycle": ["warn", { "maxDepth": 1, "ignoreExternal": true }],
|
||||
"typescript/no-explicit-any": "warn",
|
||||
"typescript/ban-ts-comment": "off"
|
||||
},
|
||||
"ignorePatterns": ["out/**", "node_modules/**", "*.config.*"]
|
||||
}
|
||||
244
ui/README.md
Normal file
244
ui/README.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Bifrost UI
|
||||
|
||||
A modern, production-ready web interface for the [Bifrost AI Gateway](https://github.com/maximhq/bifrost) - providing real-time monitoring, configuration management, and comprehensive observability for your AI infrastructure.
|
||||
|
||||
## Overview
|
||||
|
||||
Bifrost UI is a React + Vite + TanStack Router web dashboard that serves as the control center for your Bifrost AI Gateway. It provides an intuitive interface to monitor AI requests, configure providers, manage MCP clients, and analyze performance metrics.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Real-time Log Monitoring** - Live streaming dashboard with WebSocket integration
|
||||
- **Provider Management** - Configure [15+ AI providers](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration)
|
||||
- **MCP Integration** - Manage [Model Context Protocol](https://docs.getbifrost.ai/features/mcp) clients for advanced AI capabilities
|
||||
- **Plugin System** - Extend functionality with [custom plugins](https://docs.getbifrost.ai/plugins/getting-started)
|
||||
- **Analytics Dashboard** - Request metrics, success rates, latency tracking, and token usage
|
||||
- **Modern UI** - Dark/light mode, responsive design, and accessible components
|
||||
- **Documentation Hub** - Built-in documentation browser and quick-start guides
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The UI is designed to work with the Bifrost HTTP transport backend. Get started with the complete setup:
|
||||
|
||||
**[Gateway Setup Guide →](https://docs.getbifrost.ai/quickstart/gateway/setting-up)**
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The development server runs on `http://localhost:3000` and connects to your Bifrost HTTP transport backend (default: `http://localhost:8080`).
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Development only - customize Bifrost backend port
|
||||
BIFROST_PORT=8080
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Framework**: React 19 + Vite + TanStack Router
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: Tailwind CSS + Radix UI components
|
||||
- **State Management**: Redux Toolkit with RTK Query
|
||||
- **Real-time**: WebSocket integration
|
||||
- **HTTP Client**: Axios with typed service layer
|
||||
- **Theme**: Dark/light mode support
|
||||
|
||||
### Integration Model
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTP/WebSocket ┌──────────────────┐
|
||||
│ Bifrost UI │ ◄─────────────────► │ Bifrost HTTP │
|
||||
│ (React+Vite) │ │ Transport (Go) │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
│ │
|
||||
│ Build artifacts │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Development**: UI runs on port 3000, connects to Go backend on port 8080
|
||||
- **Production**: UI built as static assets served directly by Go HTTP transport
|
||||
- **Communication**: REST API + WebSocket for real-time features
|
||||
|
||||
## Features
|
||||
|
||||
### Real-time Log Monitoring
|
||||
|
||||
The main dashboard provides comprehensive request monitoring with live updates via WebSocket, advanced filtering, and detailed request/response inspection.
|
||||
|
||||
**[Learn More →](https://docs.getbifrost.ai/features/observability)**
|
||||
|
||||
### Provider Configuration
|
||||
|
||||
Manage all your AI providers from a unified interface with support for multiple API keys, custom network configuration, and provider-specific settings.
|
||||
|
||||
**[View All Providers →](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration)**
|
||||
|
||||
### MCP Client Management
|
||||
|
||||
Model Context Protocol integration for advanced AI capabilities including tool integration and connection monitoring.
|
||||
|
||||
**[MCP Documentation →](https://docs.getbifrost.ai/features/mcp)**
|
||||
|
||||
### Plugin Ecosystem
|
||||
|
||||
Extend Bifrost with powerful plugins for observability, testing, caching, and custom functionality.
|
||||
|
||||
**Available Plugins:**
|
||||
|
||||
- [Maxim Logger](https://docs.getbifrost.ai/features/observability/maxim) - Advanced LLM observability
|
||||
- [Response Mocker](https://docs.getbifrost.ai/features/plugins/mocker) - Mock responses for testing
|
||||
- [Semantic Cache](https://docs.getbifrost.ai/features/semantic-caching) - Intelligent response caching
|
||||
- [OpenTelemetry](https://docs.getbifrost.ai/features/observability/otel) - Distributed tracing
|
||||
|
||||
**[Plugin Development Guide →](https://docs.getbifrost.ai/plugins/getting-started)**
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
ui/
|
||||
├── app/ # TanStack Router pages
|
||||
│ ├── page.tsx # Main logs dashboard
|
||||
│ ├── config/ # Provider & MCP configuration
|
||||
│ ├── docs/ # Documentation browser
|
||||
│ └── plugins/ # Plugin management
|
||||
├── components/ # Reusable UI components
|
||||
│ ├── logs/ # Log monitoring components
|
||||
│ ├── config/ # Configuration forms
|
||||
│ └── ui/ # Base UI components (Radix)
|
||||
├── hooks/ # Custom React hooks
|
||||
├── lib/ # Utilities and services
|
||||
│ ├── store/ # Redux store and API slices
|
||||
│ ├── types/ # TypeScript definitions
|
||||
│ └── utils/ # Helper functions
|
||||
└── scripts/ # Build and deployment scripts
|
||||
```
|
||||
|
||||
### API Integration
|
||||
|
||||
The UI uses Redux Toolkit + RTK Query for state management and API communication with the Bifrost HTTP transport backend:
|
||||
|
||||
```typescript
|
||||
// Example API usage with RTK Query
|
||||
import { useGetLogsQuery, useCreateProviderMutation, getErrorMessage } from "@/lib/store";
|
||||
|
||||
// Get real-time logs with automatic caching
|
||||
const { data: logs, error, isLoading } = useGetLogsQuery({ filters, pagination });
|
||||
|
||||
// Configure provider with optimistic updates
|
||||
const [createProvider] = useCreateProviderMutation();
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
await createProvider({
|
||||
provider: "openai",
|
||||
keys: [{ value: "sk-...", models: ["gpt-4"], weight: 1 }],
|
||||
// ... other config
|
||||
}).unwrap();
|
||||
// Success handling
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Component Guidelines
|
||||
|
||||
- **Composition**: Use Radix UI primitives for accessibility
|
||||
- **Styling**: Tailwind CSS with CSS variables for theming
|
||||
- **Types**: Full TypeScript coverage matching Go backend schemas
|
||||
- **Error Handling**: Consistent error states and user feedback
|
||||
|
||||
### Adding New Features
|
||||
|
||||
1. **Backend Integration**: Add API endpoints to RTK Query slices in `lib/store/`
|
||||
2. **Type Definitions**: Update types in `lib/types/`
|
||||
3. **UI Components**: Build with Radix UI and Tailwind
|
||||
4. **State Management**: Use RTK Query for API state, React hooks for local state
|
||||
5. **Real-time Updates**: Integrate WebSocket events when applicable
|
||||
|
||||
## Configuration
|
||||
|
||||
### Provider Setup
|
||||
|
||||
The UI supports comprehensive provider configuration including API keys with model assignments, network settings, and provider-specific options.
|
||||
|
||||
**[Complete Provider Configuration Guide →](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration)**
|
||||
|
||||
### Governance & Access Control
|
||||
|
||||
Configure virtual keys, budget limits, rate limiting, and team-based access control through the UI.
|
||||
|
||||
**[Governance Documentation →](https://docs.getbifrost.ai/features/governance)**
|
||||
|
||||
### Real-time Features
|
||||
|
||||
WebSocket connection provides live log streaming, connection status monitoring, automatic reconnection, and filtered real-time updates.
|
||||
|
||||
**[Observability Features →](https://docs.getbifrost.ai/features/observability)**
|
||||
|
||||
## Monitoring & Analytics
|
||||
|
||||
The dashboard provides comprehensive observability including request metrics, token usage tracking, provider performance analysis, error categorization, and historical trend analysis.
|
||||
|
||||
**[Performance Benchmarks →](https://docs.getbifrost.ai/benchmarking/getting-started)**
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! See our [Contributing Guide](https://docs.getbifrost.ai/contributing/setting-up-repo) for:
|
||||
|
||||
- Code conventions and style guide
|
||||
- Development setup and workflow
|
||||
- Adding new providers or features
|
||||
- Plugin development guidelines
|
||||
|
||||
## Documentation
|
||||
|
||||
**Complete Documentation:** [https://docs.getbifrost.ai](https://docs.getbifrost.ai)
|
||||
|
||||
### Quick Links
|
||||
|
||||
- [Gateway Setup](https://docs.getbifrost.ai/quickstart/gateway/setting-up) - Get started in 30 seconds
|
||||
- [Provider Configuration](https://docs.getbifrost.ai/quickstart/gateway/provider-configuration) - Multi-provider setup
|
||||
- [MCP Integration](https://docs.getbifrost.ai/features/mcp) - External tool calling
|
||||
- [Plugin Development](https://docs.getbifrost.ai/plugins/getting-started) - Build custom plugins
|
||||
- [Architecture](https://docs.getbifrost.ai/architecture) - System design and internals
|
||||
|
||||
## Need Help?
|
||||
|
||||
**[Join our Discord](https://discord.gg/exN5KAydbU)** for community support and discussions.
|
||||
|
||||
Get help with:
|
||||
|
||||
- Quick setup assistance and troubleshooting
|
||||
- Best practices and configuration tips
|
||||
- Community discussions and support
|
||||
- Real-time help with integrations
|
||||
|
||||
## Links
|
||||
|
||||
- **Main Repository**: [github.com/maximhq/bifrost](https://github.com/maximhq/bifrost)
|
||||
- **HTTP Transport**: [../transports/bifrost-http](../transports/bifrost-http)
|
||||
- **Documentation**: [docs.getbifrost.ai](https://docs.getbifrost.ai)
|
||||
- **Website**: [getbifrost.ai](https://www.getbifrost.ai)
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the Apache 2.0 License - see the [LICENSE](../LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ by [Maxim](https://github.com/maximhq)
|
||||
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,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user