mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-01-29 22:56:36 +08:00
feat: Implement pluggable multi-provider sandbox architecture (#12820)
## Summary Implement a flexible sandbox provider system supporting both self-managed (Docker) and SaaS (Aliyun Code Interpreter) backends for secure code execution in agent workflows. **Key Changes:** - ✅ Aliyun Code Interpreter provider using official `agentrun-sdk>=0.0.16` - ✅ Self-managed provider with gVisor (runsc) security - ✅ Arguments parameter support for dynamic code execution - ✅ Database-only configuration (removed fallback logic) - ✅ Configuration scripts for quick setup Issue #12479 ## Features ### 🔌 Provider Abstraction Layer **1. Self-Managed Provider** (`agent/sandbox/providers/self_managed.py`) - Wraps existing executor_manager HTTP API - gVisor (runsc) for secure container isolation - Configurable pool size, timeout, retry logic - Languages: Python, Node.js, JavaScript - ⚠️ **Requires**: gVisor installation, Docker, base images **2. Aliyun Code Interpreter** (`agent/sandbox/providers/aliyun_codeinterpreter.py`) - SaaS integration using official agentrun-sdk - Serverless microVM execution with auto-authentication - Hard timeout: 30 seconds max - Credentials: `AGENTRUN_ACCESS_KEY_ID`, `AGENTRUN_ACCESS_KEY_SECRET`, `AGENTRUN_ACCOUNT_ID`, `AGENTRUN_REGION` - Automatically wraps code to call `main()` function **3. E2B Provider** (`agent/sandbox/providers/e2b.py`) - Placeholder for future integration ### ⚙️ Configuration System - `conf/system_settings.json`: Default provider = `aliyun_codeinterpreter` - `agent/sandbox/client.py`: Enforces database-only configuration - Admin UI: `/admin/sandbox-settings` - Configuration validation via `validate_config()` method - Health checks for all providers ### 🎯 Key Capabilities **Arguments Parameter Support:** All providers support passing arguments to `main()` function: ```python # User code def main(name: str, count: int) -> dict: return {"message": f"Hello {name}!" * count} # Executed with: arguments={"name": "World", "count": 3} # Result: {"message": "Hello World!Hello World!Hello World!"} ``` **Self-Describing Providers:** Each provider implements `get_config_schema()` returning form configuration for Admin UI **Error Handling:** Structured `ExecutionResult` with stdout, stderr, exit_code, execution_time ## Configuration Scripts Two scripts for quick Aliyun sandbox setup: **Shell Script (requires jq):** ```bash source scripts/configure_aliyun_sandbox.sh ``` **Python Script (interactive):** ```bash python3 scripts/configure_aliyun_sandbox.py ``` ## Testing ```bash # Unit tests uv run pytest agent/sandbox/tests/test_providers.py -v # Aliyun provider tests uv run pytest agent/sandbox/tests/test_aliyun_codeinterpreter.py -v # Integration tests (requires credentials) uv run pytest agent/sandbox/tests/test_aliyun_codeinterpreter_integration.py -v # Quick SDK validation python3 agent/sandbox/tests/verify_sdk.py ``` **Test Coverage:** - 30 unit tests for provider abstraction - Provider-specific tests for Aliyun - Integration tests with real API - Security tests for executor_manager ## Documentation - `docs/develop/sandbox_spec.md` - Complete architecture specification - `agent/sandbox/tests/MIGRATION_GUIDE.md` - Migration from legacy sandbox - `agent/sandbox/tests/QUICKSTART.md` - Quick start guide - `agent/sandbox/tests/README.md` - Testing documentation ## Breaking Changes ⚠️ **Migration Required:** 1. **Directory Move**: `sandbox/` → `agent/sandbox/` - Update imports: `from sandbox.` → `from agent.sandbox.` 2. **Mandatory Configuration**: - SystemSettings must have `sandbox.provider_type` configured - Removed fallback default values - Configuration must exist in database (from `conf/system_settings.json`) 3. **Aliyun Credentials**: - Requires `AGENTRUN_*` environment variables (not `ALIYUN_*`) - `AGENTRUN_ACCOUNT_ID` is now required (Aliyun primary account ID) 4. **Self-Managed Provider**: - gVisor (runsc) must be installed for security - Install: `go install gvisor.dev/gvisor/runsc@latest` ## Database Schema Changes ```python # SystemSettings.value: CharField → TextField api/db/db_models.py: Changed for unlimited config length # SystemSettingsService.get_by_name(): Fixed query precision api/db/services/system_settings_service.py: startswith → exact match ``` ## Files Changed ### Backend (Python) - `agent/sandbox/providers/base.py` - SandboxProvider ABC interface - `agent/sandbox/providers/manager.py` - ProviderManager - `agent/sandbox/providers/self_managed.py` - Self-managed provider - `agent/sandbox/providers/aliyun_codeinterpreter.py` - Aliyun provider - `agent/sandbox/providers/e2b.py` - E2B provider (placeholder) - `agent/sandbox/client.py` - Unified client (enforces DB-only config) - `agent/tools/code_exec.py` - Updated to use provider system - `admin/server/services.py` - SandboxMgr with registry & validation - `admin/server/routes.py` - 5 sandbox API endpoints - `conf/system_settings.json` - Default: aliyun_codeinterpreter - `api/db/db_models.py` - TextField for SystemSettings.value - `api/db/services/system_settings_service.py` - Exact match query ### Frontend (TypeScript/React) - `web/src/pages/admin/sandbox-settings.tsx` - Settings UI - `web/src/services/admin-service.ts` - Sandbox service functions - `web/src/services/admin.service.d.ts` - Type definitions - `web/src/utils/api.ts` - Sandbox API endpoints ### Documentation - `docs/develop/sandbox_spec.md` - Architecture spec - `agent/sandbox/tests/MIGRATION_GUIDE.md` - Migration guide - `agent/sandbox/tests/QUICKSTART.md` - Quick start - `agent/sandbox/tests/README.md` - Testing guide ### Configuration Scripts - `scripts/configure_aliyun_sandbox.sh` - Shell script (jq) - `scripts/configure_aliyun_sandbox.py` - Python script ### Tests - `agent/sandbox/tests/test_providers.py` - 30 unit tests - `agent/sandbox/tests/test_aliyun_codeinterpreter.py` - Provider tests - `agent/sandbox/tests/test_aliyun_codeinterpreter_integration.py` - Integration tests - `agent/sandbox/tests/verify_sdk.py` - SDK validation ## Architecture ``` Admin UI → Admin API → SandboxMgr → ProviderManager → [SelfManaged|Aliyun|E2B] ↓ SystemSettings ``` ## Usage ### 1. Configure Provider **Via Admin UI:** 1. Navigate to `/admin/sandbox-settings` 2. Select provider (Aliyun Code Interpreter / Self-Managed) 3. Fill in configuration 4. Click "Test Connection" to verify 5. Click "Save" to apply **Via Configuration Scripts:** ```bash # Aliyun provider export AGENTRUN_ACCESS_KEY_ID="xxx" export AGENTRUN_ACCESS_KEY_SECRET="yyy" export AGENTRUN_ACCOUNT_ID="zzz" export AGENTRUN_REGION="cn-shanghai" source scripts/configure_aliyun_sandbox.sh ``` ### 2. Restart Service ```bash cd docker docker compose restart ragflow-server ``` ### 3. Execute Code in Agent ```python from agent.sandbox.client import execute_code result = execute_code( code='def main(name: str) -> dict: return {"message": f"Hello {name}!"}', language="python", timeout=30, arguments={"name": "World"} ) print(result.stdout) # {"message": "Hello World!"} ``` ## Troubleshooting ### "Container pool is busy" (Self-Managed) - **Cause**: Pool exhausted (default: 1 container in `.env`) - **Fix**: Increase `SANDBOX_EXECUTOR_MANAGER_POOL_SIZE` to 5+ ### "Sandbox provider type not configured" - **Cause**: Database missing configuration - **Fix**: Run config script or set via Admin UI ### "gVisor not found" - **Cause**: runsc not installed - **Fix**: `go install gvisor.dev/gvisor/runsc@latest && sudo cp ~/go/bin/runsc /usr/local/bin/` ### Aliyun authentication errors - **Cause**: Wrong environment variable names - **Fix**: Use `AGENTRUN_*` prefix (not `ALIYUN_*`) ## Checklist - [x] All tests passing (30 unit tests + integration tests) - [x] Documentation updated (spec, migration guide, quickstart) - [x] Type definitions added (TypeScript) - [x] Admin UI implemented - [x] Configuration validation - [x] Health checks implemented - [x] Error handling with structured results - [x] Breaking changes documented - [x] Configuration scripts created - [x] gVisor requirements documented Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@ -2,12 +2,39 @@ import { ExternalToast, toast } from 'sonner';
|
||||
|
||||
const configuration: ExternalToast = { duration: 2500, position: 'top-center' };
|
||||
|
||||
type MessageOptions = {
|
||||
message: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const message = {
|
||||
success: (msg: string) => {
|
||||
toast.success(msg, configuration);
|
||||
},
|
||||
error: (msg: string) => {
|
||||
toast.error(msg, configuration);
|
||||
error: (msg: string | MessageOptions, data?: ExternalToast) => {
|
||||
let messageText: string;
|
||||
let options: ExternalToast = { ...configuration };
|
||||
|
||||
if (typeof msg === 'object') {
|
||||
// Object-style call: message.error({ message: '...', description: '...', duration: 3 })
|
||||
messageText = msg.message;
|
||||
if (msg.description) {
|
||||
messageText += `\n${msg.description}`;
|
||||
}
|
||||
if (msg.duration !== undefined) {
|
||||
options.duration = msg.duration * 1000; // Convert to milliseconds
|
||||
}
|
||||
} else {
|
||||
// String-style call: message.error('text', { description: '...' })
|
||||
messageText = msg;
|
||||
if (data?.description) {
|
||||
messageText += `\n${data.description}`;
|
||||
}
|
||||
options = { ...options, ...data };
|
||||
}
|
||||
|
||||
toast.error(messageText, options);
|
||||
},
|
||||
warning: (msg: string) => {
|
||||
toast.warning(msg, configuration);
|
||||
|
||||
@ -2451,6 +2451,7 @@ Important structured information may include: names, dates, locations, events, k
|
||||
|
||||
serviceStatus: 'Service status',
|
||||
userManagement: 'User management',
|
||||
sandboxSettings: 'Sandbox settings',
|
||||
registrationWhitelist: 'Registration whitelist',
|
||||
roles: 'Roles',
|
||||
monitoring: 'Monitoring',
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
LucideSquareUserRound,
|
||||
LucideUserCog,
|
||||
LucideUserStar,
|
||||
LucideZap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -45,6 +46,11 @@ const AdminNavigationLayout = () => {
|
||||
name: t('admin.userManagement'),
|
||||
icon: <LucideUserCog className="size-[1em]" />,
|
||||
},
|
||||
{
|
||||
path: Routes.AdminSandboxSettings,
|
||||
name: t('admin.sandboxSettings'),
|
||||
icon: <LucideZap className="size-[1em]" />,
|
||||
},
|
||||
...(IS_ENTERPRISE
|
||||
? [
|
||||
{
|
||||
|
||||
486
web/src/pages/admin/sandbox-settings.tsx
Normal file
486
web/src/pages/admin/sandbox-settings.tsx
Normal file
@ -0,0 +1,486 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
LucideCloud,
|
||||
LucideLoader2,
|
||||
LucideServer,
|
||||
LucideZap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
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 {
|
||||
getSandboxConfig,
|
||||
getSandboxProviderSchema,
|
||||
listSandboxProviders,
|
||||
setSandboxConfig,
|
||||
testSandboxConnection,
|
||||
} from '@/services/admin-service';
|
||||
|
||||
import message from '@/components/ui/message';
|
||||
|
||||
// Provider icons mapping
|
||||
const PROVIDER_ICONS: Record<string, React.ElementType> = {
|
||||
self_managed: LucideServer,
|
||||
aliyun_codeinterpreter: LucideCloud,
|
||||
e2b: LucideZap,
|
||||
};
|
||||
|
||||
function AdminSandboxSettings() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
|
||||
const [testModalOpen, setTestModalOpen] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: {
|
||||
exit_code: number;
|
||||
execution_time: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
} | null>(null);
|
||||
|
||||
// Fetch providers list
|
||||
const { data: providers = [], isLoading: providersLoading } = useQuery({
|
||||
queryKey: ['admin/listSandboxProviders'],
|
||||
queryFn: async () => (await listSandboxProviders()).data.data,
|
||||
});
|
||||
|
||||
// Fetch current config
|
||||
const { data: currentConfig, isLoading: configLoading } = useQuery({
|
||||
queryKey: ['admin/getSandboxConfig'],
|
||||
queryFn: async () => (await getSandboxConfig()).data.data,
|
||||
});
|
||||
|
||||
// Fetch provider schema when provider is selected
|
||||
const { data: providerSchema = {} } = useQuery({
|
||||
queryKey: ['admin/getSandboxProviderSchema', selectedProvider],
|
||||
queryFn: async () =>
|
||||
(await getSandboxProviderSchema(selectedProvider!)).data.data,
|
||||
enabled: !!selectedProvider,
|
||||
});
|
||||
|
||||
// Set config mutation
|
||||
const setConfigMutation = useMutation({
|
||||
mutationFn: async (params: {
|
||||
providerType: string;
|
||||
config: Record<string, unknown>;
|
||||
}) => (await setSandboxConfig(params)).data,
|
||||
onSuccess: () => {
|
||||
message.success('Sandbox configuration updated successfully');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin/getSandboxConfig'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`Failed to update configuration: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Test connection mutation
|
||||
const testConnectionMutation = useMutation({
|
||||
mutationFn: async (params: {
|
||||
providerType: string;
|
||||
config: Record<string, unknown>;
|
||||
}) => (await testSandboxConnection(params)).data.data,
|
||||
onSuccess: (data) => {
|
||||
setTestResult(data);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setTestResult({ success: false, message: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize state when current config is loaded
|
||||
useEffect(() => {
|
||||
if (currentConfig) {
|
||||
setSelectedProvider(currentConfig.provider_type);
|
||||
setConfigValues(currentConfig.config || {});
|
||||
}
|
||||
}, [currentConfig]);
|
||||
|
||||
// Apply schema defaults when provider schema changes
|
||||
useEffect(() => {
|
||||
if (providerSchema && Object.keys(providerSchema).length > 0) {
|
||||
setConfigValues((prev) => {
|
||||
const mergedConfig = { ...prev };
|
||||
// Apply schema defaults for any missing fields
|
||||
Object.entries(providerSchema).forEach(([fieldName, schema]) => {
|
||||
if (
|
||||
mergedConfig[fieldName] === undefined &&
|
||||
schema.default !== undefined
|
||||
) {
|
||||
mergedConfig[fieldName] = schema.default;
|
||||
}
|
||||
});
|
||||
return mergedConfig;
|
||||
});
|
||||
}
|
||||
}, [providerSchema]);
|
||||
|
||||
// Handle provider change
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
setSelectedProvider(providerId);
|
||||
// Force refetch config and schema from backend when switching providers
|
||||
queryClient.invalidateQueries({ queryKey: ['admin/getSandboxConfig'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin/getSandboxProviderSchema'],
|
||||
});
|
||||
};
|
||||
|
||||
// Handle config value change
|
||||
const handleConfigValueChange = (fieldName: string, value: unknown) => {
|
||||
setConfigValues((prev) => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
// Handle save
|
||||
const handleSave = () => {
|
||||
if (!selectedProvider) return;
|
||||
|
||||
setConfigMutation.mutate({
|
||||
providerType: selectedProvider,
|
||||
config: configValues,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle test connection
|
||||
const handleTestConnection = () => {
|
||||
if (!selectedProvider) return;
|
||||
|
||||
setTestModalOpen(true);
|
||||
setTestResult(null);
|
||||
testConnectionMutation.mutate({
|
||||
providerType: selectedProvider,
|
||||
config: configValues,
|
||||
});
|
||||
};
|
||||
|
||||
// Render config field based on schema
|
||||
const renderConfigField = (
|
||||
fieldName: string,
|
||||
schema: AdminService.SandboxConfigField,
|
||||
) => {
|
||||
const value = configValues[fieldName] ?? schema.default ?? '';
|
||||
const isSecret = schema.secret ?? false;
|
||||
|
||||
switch (schema.type) {
|
||||
case 'string':
|
||||
if (isSecret) {
|
||||
return (
|
||||
<Input
|
||||
type="password"
|
||||
id={fieldName}
|
||||
placeholder={schema.placeholder}
|
||||
value={value as string}
|
||||
onChange={(e) =>
|
||||
handleConfigValueChange(fieldName, e.target.value)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Textarea
|
||||
id={fieldName}
|
||||
placeholder={schema.placeholder}
|
||||
value={value as string}
|
||||
onChange={(e) => handleConfigValueChange(fieldName, e.target.value)}
|
||||
rows={
|
||||
schema.description?.includes('endpoint') ||
|
||||
schema.description?.includes('URL')
|
||||
? 1
|
||||
: 3
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'integer':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
id={fieldName}
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
value={value as number}
|
||||
onChange={(e) =>
|
||||
handleConfigValueChange(fieldName, parseInt(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
id={fieldName}
|
||||
checked={value as boolean}
|
||||
onCheckedChange={(checked) =>
|
||||
handleConfigValueChange(fieldName, checked)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (providersLoading || configLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<LucideLoader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedProviderData = providers.find((p) => p.id === selectedProvider);
|
||||
const ProviderIcon = selectedProvider
|
||||
? PROVIDER_ICONS[selectedProvider] || LucideServer
|
||||
: LucideServer;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Sandbox Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your code execution sandbox provider. The sandbox is used by
|
||||
the Code component in agents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Provider Selection</CardTitle>
|
||||
<CardDescription>
|
||||
Choose a sandbox provider for code execution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{providers.map((provider) => {
|
||||
const Icon = PROVIDER_ICONS[provider.id] || LucideServer;
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`relative rounded-lg border p-4 cursor-pointer transition-all hover:bg-accent ${
|
||||
selectedProvider === provider.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border'
|
||||
}`}
|
||||
onClick={() => handleProviderChange(provider.id)}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Icon className="w-5 h-5 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold">{provider.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{provider.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{provider.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-secondary text-secondary-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Provider Configuration */}
|
||||
{selectedProvider && selectedProviderData && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ProviderIcon className="w-5 h-5" />
|
||||
{selectedProviderData.name} Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the connection settings for{' '}
|
||||
{selectedProviderData.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={setConfigMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{setConfigMutation.isPending ? (
|
||||
<>
|
||||
<LucideLoader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Configuration'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTestConnection}
|
||||
disabled={testConnectionMutation.isPending}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{testConnectionMutation.isPending ? (
|
||||
<>
|
||||
<LucideLoader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
'Test Connection'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(providerSchema).map(([fieldName, schema]) => (
|
||||
<div key={fieldName} className="space-y-2">
|
||||
<Label htmlFor={fieldName}>
|
||||
{schema.label || fieldName}
|
||||
{schema.required && (
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{renderConfigField(fieldName, schema)}
|
||||
{schema.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schema.description}
|
||||
</p>
|
||||
)}
|
||||
{schema.type === 'integer' &&
|
||||
(schema.min !== undefined || schema.max !== undefined) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schema.min !== undefined && `Minimum: ${schema.min}`}
|
||||
{schema.min !== undefined &&
|
||||
schema.max !== undefined &&
|
||||
' • '}
|
||||
{schema.max !== undefined && `Maximum: ${schema.max}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Test Result Modal */}
|
||||
<Dialog open={testModalOpen} onOpenChange={setTestModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connection Test Result</DialogTitle>
|
||||
<DialogDescription>
|
||||
{testResult === null
|
||||
? 'Testing connection to sandbox provider...'
|
||||
: testResult.success
|
||||
? 'Successfully connected to sandbox provider'
|
||||
: 'Failed to connect to sandbox provider'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{testResult === null ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LucideLoader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Summary message */}
|
||||
<div
|
||||
className={`p-4 rounded-lg ${
|
||||
testResult.success
|
||||
? 'bg-green-50 text-green-900 dark:bg-green-900/20 dark:text-green-100'
|
||||
: 'bg-red-50 text-red-900 dark:bg-red-900/20 dark:text-red-100'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{testResult.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Detailed execution results */}
|
||||
{testResult.details && (
|
||||
<div className="space-y-3">
|
||||
{/* Exit code and execution time */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="p-2 bg-muted rounded">
|
||||
<span className="font-medium">Exit Code:</span>{' '}
|
||||
{testResult.details.exit_code}
|
||||
</div>
|
||||
<div className="p-2 bg-muted rounded">
|
||||
<span className="font-medium">Execution Time:</span>{' '}
|
||||
{testResult.details.execution_time?.toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Standard output */}
|
||||
{testResult.details.stdout && (
|
||||
<div className="p-3 bg-muted rounded">
|
||||
<p className="text-xs font-medium mb-2 text-muted-foreground">
|
||||
Standard Output:
|
||||
</p>
|
||||
<pre className="text-xs whitespace-pre-wrap break-words font-mono">
|
||||
{testResult.details.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard error (stack traces) */}
|
||||
{testResult.details.stderr && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
|
||||
<p className="text-xs font-medium mb-2 text-red-900 dark:text-red-100">
|
||||
Error Output / Stack Trace:
|
||||
</p>
|
||||
<pre className="text-xs whitespace-pre-wrap break-words font-mono text-red-900 dark:text-red-100">
|
||||
{testResult.details.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setTestModalOpen(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminSandboxSettings;
|
||||
@ -60,6 +60,7 @@ export enum Routes {
|
||||
Admin = '/admin',
|
||||
AdminServices = `${Admin}/services`,
|
||||
AdminUserManagement = `${Admin}/users`,
|
||||
AdminSandboxSettings = `${Admin}/sandbox-settings`,
|
||||
AdminWhitelist = `${Admin}/whitelist`,
|
||||
AdminRoles = `${Admin}/roles`,
|
||||
AdminMonitoring = `${Admin}/monitoring`,
|
||||
@ -419,6 +420,10 @@ const routeConfig = [
|
||||
path: Routes.AdminUserManagement,
|
||||
Component: lazy(() => import('@/pages/admin/users')),
|
||||
},
|
||||
{
|
||||
path: Routes.AdminSandboxSettings,
|
||||
Component: lazy(() => import('@/pages/admin/sandbox-settings')),
|
||||
},
|
||||
...(IS_ENTERPRISE
|
||||
? [
|
||||
{
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { history } from '@/utils/simple-history-util';
|
||||
import { message, notification } from 'antd';
|
||||
import axios from 'axios';
|
||||
|
||||
import message from '@/components/ui/message';
|
||||
import { Authorization } from '@/constants/authorization';
|
||||
import i18n from '@/locales/config';
|
||||
import { Routes } from '@/routes';
|
||||
@ -41,38 +41,34 @@ request.interceptors.response.use(
|
||||
if (data?.code === 100) {
|
||||
message.error(data?.message);
|
||||
} else if (data?.code === 401) {
|
||||
notification.error({
|
||||
message: data?.message,
|
||||
message.error(data?.message, {
|
||||
description: data?.message,
|
||||
duration: 3,
|
||||
});
|
||||
|
||||
authorizationUtil.removeAll();
|
||||
history.push(Routes.Admin);
|
||||
window.location.reload();
|
||||
} else if (data?.code && data.code !== 0) {
|
||||
notification.error({
|
||||
message: `${i18n.t('message.hint')}: ${data?.code}`,
|
||||
message.error(`${i18n.t('message.hint')}: ${data?.code}`, {
|
||||
description: data?.message,
|
||||
duration: 3,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
const { response, message } = error;
|
||||
const { response } = error;
|
||||
const { data } = response ?? {};
|
||||
|
||||
if (error.message === 'Failed to fetch') {
|
||||
notification.error({
|
||||
message.error({
|
||||
description: i18n.t('message.networkAnomalyDescription'),
|
||||
message: i18n.t('message.networkAnomaly'),
|
||||
});
|
||||
} else if (data?.code === 100) {
|
||||
message.error(data?.message);
|
||||
} else if (response.status === 401 || data?.code === 401) {
|
||||
notification.error({
|
||||
message.error({
|
||||
message: data?.message || response.statusText,
|
||||
description:
|
||||
data?.message || RetcodeMessage[response?.status as ResultCode],
|
||||
@ -83,13 +79,13 @@ request.interceptors.response.use(
|
||||
history.push(Routes.Admin);
|
||||
window.location.reload();
|
||||
} else if (data?.code && data.code !== 0) {
|
||||
notification.error({
|
||||
message.error({
|
||||
message: `${i18n.t('message.hint')}: ${data?.code}`,
|
||||
description: data?.message,
|
||||
duration: 3,
|
||||
});
|
||||
} else if (response.status) {
|
||||
notification.error({
|
||||
message.error({
|
||||
message: `${i18n.t('message.requestError')} ${response.status}: ${response.config.url}`,
|
||||
description:
|
||||
RetcodeMessage[response.status as ResultCode] || response.statusText,
|
||||
@ -138,6 +134,12 @@ const {
|
||||
adminImportWhitelist,
|
||||
|
||||
adminGetSystemVersion,
|
||||
|
||||
adminListSandboxProviders,
|
||||
adminGetSandboxProviderSchema,
|
||||
adminGetSandboxConfig,
|
||||
adminSetSandboxConfig,
|
||||
adminTestSandboxConnection,
|
||||
} = api;
|
||||
|
||||
type ResponseData<D = NonNullable<unknown>> = {
|
||||
@ -270,3 +272,49 @@ export const importWhitelistFromExcel = (file: File) => {
|
||||
|
||||
export const getSystemVersion = () =>
|
||||
request.get<ResponseData<{ version: string }>>(adminGetSystemVersion);
|
||||
|
||||
// Sandbox settings APIs
|
||||
export const listSandboxProviders = () =>
|
||||
request.get<ResponseData<AdminService.SandboxProvider[]>>(
|
||||
adminListSandboxProviders,
|
||||
);
|
||||
|
||||
export const getSandboxProviderSchema = (providerId: string) =>
|
||||
request.get<ResponseData<Record<string, AdminService.SandboxConfigField>>>(
|
||||
adminGetSandboxProviderSchema(providerId),
|
||||
);
|
||||
|
||||
export const getSandboxConfig = () =>
|
||||
request.get<ResponseData<AdminService.SandboxConfig>>(adminGetSandboxConfig);
|
||||
|
||||
export const setSandboxConfig = (params: {
|
||||
providerType: string;
|
||||
config: Record<string, unknown>;
|
||||
}) =>
|
||||
request.post<ResponseData<AdminService.SandboxConfig>>(
|
||||
adminSetSandboxConfig,
|
||||
{
|
||||
provider_type: params.providerType,
|
||||
config: params.config,
|
||||
},
|
||||
);
|
||||
|
||||
export const testSandboxConnection = (params: {
|
||||
providerType: string;
|
||||
config: Record<string, unknown>;
|
||||
}) =>
|
||||
request.post<
|
||||
ResponseData<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: {
|
||||
exit_code: number;
|
||||
execution_time: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
}>
|
||||
>(adminTestSandboxConnection, {
|
||||
provider_type: params.providerType,
|
||||
config: params.config,
|
||||
});
|
||||
|
||||
25
web/src/services/admin.service.d.ts
vendored
25
web/src/services/admin.service.d.ts
vendored
@ -166,4 +166,29 @@ declare module AdminService {
|
||||
update_date: string;
|
||||
update_time: number;
|
||||
};
|
||||
|
||||
// Sandbox settings types
|
||||
export type SandboxProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type SandboxConfigField = {
|
||||
type: 'string' | 'integer' | 'boolean' | 'json';
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
default?: string | number | boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
description?: string;
|
||||
secret?: boolean;
|
||||
};
|
||||
|
||||
export type SandboxConfig = {
|
||||
provider_type: string;
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@ -312,4 +312,12 @@ export default {
|
||||
adminImportWhitelist: `${ExternalApi}${api_host}/admin/whitelist/batch`,
|
||||
|
||||
adminGetSystemVersion: `${ExternalApi}${api_host}/admin/version`,
|
||||
|
||||
// Sandbox settings
|
||||
adminListSandboxProviders: `${ExternalApi}${api_host}/admin/sandbox/providers`,
|
||||
adminGetSandboxProviderSchema: (providerId: string) =>
|
||||
`${ExternalApi}${api_host}/admin/sandbox/providers/${providerId}/schema`,
|
||||
adminGetSandboxConfig: `${ExternalApi}${api_host}/admin/sandbox/config`,
|
||||
adminSetSandboxConfig: `${ExternalApi}${api_host}/admin/sandbox/config`,
|
||||
adminTestSandboxConnection: `${ExternalApi}${api_host}/admin/sandbox/test`,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user