Feat: add local & ssh provider in admin panel (#15039)

### What problem does this PR solve?

Feat: add local & ssh provider in admin panel

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Magicbook1108
2026-05-20 16:56:20 +08:00
committed by GitHub
parent 6499bce2a6
commit b28e134944
19 changed files with 1836 additions and 408 deletions

View File

@@ -1,14 +1,17 @@
import { useEffect, useState } from 'react';
import { FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
LucideChevronDown,
LucideCloud,
LucideLink,
LucideLoader2,
LucideMonitor,
LucideSave,
LucideServer,
LucideTerminal,
LucideZap,
} from 'lucide-react';
@@ -20,6 +23,11 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Dialog,
DialogContent,
@@ -31,6 +39,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import {
@@ -48,7 +57,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
// Provider icons mapping
const PROVIDER_ICONS: Record<string, React.ElementType> = {
local: LucideMonitor,
self_managed: LucideServer,
ssh: LucideTerminal,
aliyun_codeinterpreter: LucideCloud,
e2b: LucideZap,
};
@@ -60,6 +71,9 @@ function AdminSandboxSettings() {
// State
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
const [sshAuthMode, setSshAuthMode] = useState<'password' | 'private_key'>(
'password',
);
const [testModalOpen, setTestModalOpen] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
@@ -134,13 +148,25 @@ function AdminSandboxSettings() {
}
}, [currentConfig]);
useEffect(() => {
if (selectedProvider !== 'ssh') {
return;
}
const hasPrivateKey = Boolean(
String(configValues.private_key ?? '').trim(),
);
setSshAuthMode(hasPrivateKey ? 'private_key' : 'password');
}, [selectedProvider, configValues.private_key]);
// 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 (schema.readonly) {
return;
}
if (
mergedConfig[fieldName] === undefined &&
schema.default !== undefined
@@ -156,8 +182,11 @@ function AdminSandboxSettings() {
// Handle provider change
const handleProviderChange = (providerId: string) => {
setSelectedProvider(providerId);
// Force refetch config and schema from backend when switching providers
queryClient.invalidateQueries({ queryKey: ['admin/getSandboxConfig'] });
if (currentConfig?.provider_type === providerId) {
setConfigValues(currentConfig.config || {});
} else {
setConfigValues({});
}
queryClient.invalidateQueries({
queryKey: ['admin/getSandboxProviderSchema'],
});
@@ -168,13 +197,50 @@ function AdminSandboxSettings() {
setConfigValues((prev) => ({ ...prev, [fieldName]: value }));
};
const handleSshAuthModeChange = (mode: 'password' | 'private_key') => {
setSshAuthMode(mode);
setConfigValues((prev) => {
if (mode === 'password') {
return {
...prev,
private_key: '',
passphrase: '',
};
}
return {
...prev,
password: '',
};
});
};
const buildSubmitConfig = () => {
if (selectedProvider !== 'ssh') {
return configValues;
}
const nextConfig = { ...configValues };
delete nextConfig.command_template;
if (sshAuthMode === 'password') {
nextConfig.private_key = '';
nextConfig.passphrase = '';
} else {
nextConfig.password = '';
}
return nextConfig;
};
// Handle save
const handleSave = () => {
const handleSave = (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!selectedProvider) return;
setConfigMutation.mutate({
providerType: selectedProvider,
config: configValues,
config: buildSubmitConfig(),
});
};
@@ -186,7 +252,7 @@ function AdminSandboxSettings() {
setTestResult(null);
testConnectionMutation.mutate({
providerType: selectedProvider,
config: configValues,
config: buildSubmitConfig(),
});
};
@@ -199,6 +265,20 @@ function AdminSandboxSettings() {
switch (schema.type) {
case 'string':
if (schema.multiline) {
return (
<Textarea
id={fieldName}
placeholder={schema.placeholder}
value={value as string}
disabled={schema.readonly}
onChange={(e) =>
handleConfigValueChange(fieldName, e.target.value)
}
rows={4}
/>
);
}
if (schema.secret) {
return (
<Input
@@ -207,6 +287,7 @@ function AdminSandboxSettings() {
className="h-10"
placeholder={schema.placeholder}
value={value as string}
disabled={schema.readonly}
onChange={(e) =>
handleConfigValueChange(fieldName, e.target.value)
}
@@ -214,17 +295,13 @@ function AdminSandboxSettings() {
);
}
return (
<Textarea
<Input
id={fieldName}
className="h-10"
placeholder={schema.placeholder}
value={value as string}
disabled={schema.readonly}
onChange={(e) => handleConfigValueChange(fieldName, e.target.value)}
rows={
schema.description?.includes('endpoint') ||
schema.description?.includes('URL')
? 1
: 3
}
/>
);
@@ -237,6 +314,7 @@ function AdminSandboxSettings() {
max={schema.max}
value={value as number}
className="h-10"
disabled={schema.readonly}
onChange={(e) =>
handleConfigValueChange(fieldName, parseInt(e.target.value) || 0)
}
@@ -248,6 +326,7 @@ function AdminSandboxSettings() {
<Switch
id={fieldName}
checked={value as boolean}
disabled={schema.readonly}
onCheckedChange={(checked) =>
handleConfigValueChange(fieldName, checked)
}
@@ -259,10 +338,95 @@ function AdminSandboxSettings() {
}
};
const isFieldRequired = (
fieldName: string,
schema: AdminService.SandboxConfigField,
) => {
if (selectedProvider === 'ssh' && fieldName === 'port') {
return true;
}
return Boolean(schema.required);
};
const getFieldPriority = (
fieldName: string,
schema: AdminService.SandboxConfigField,
) => {
const preferredOrder = [
'host',
'username',
'port',
'password',
'private_key',
'passphrase',
'work_dir',
'python_bin',
'node_bin',
];
const preferredIndex = preferredOrder.indexOf(fieldName);
if (preferredIndex !== -1) {
return preferredIndex;
}
if (schema.required) {
return 100;
}
return 200;
};
const sortFields = (entries: [string, AdminService.SandboxConfigField][]) =>
[...entries].sort(([fieldNameA, schemaA], [fieldNameB, schemaB]) => {
const priorityDiff =
getFieldPriority(fieldNameA, schemaA) -
getFieldPriority(fieldNameB, schemaB);
if (priorityDiff !== 0) {
return priorityDiff;
}
if (schemaA.required !== schemaB.required) {
return schemaA.required ? -1 : 1;
}
return fieldNameA.localeCompare(fieldNameB);
});
const selectedProviderData = providers.find((p) => p.id === selectedProvider);
const ProviderIcon = selectedProvider
? PROVIDER_ICONS[selectedProvider] || LucideServer
: LucideServer;
const runtimeFields = sortFields(
Object.entries(providerSchema).filter(
([, schema]) => schema.scope !== 'deployment',
),
);
const deploymentFields = sortFields(
Object.entries(providerSchema).filter(
([, schema]) => schema.scope === 'deployment',
),
);
const isSshProvider = selectedProvider === 'ssh';
const sshIdentityFields = new Set(['host', 'username', 'port']);
const sshPasswordFields = new Set(['password']);
const sshPrivateKeyFields = new Set(['private_key', 'passphrase']);
const sshExecutionFields = new Set(['work_dir', 'python_bin', 'node_bin']);
const sshSharedFields = new Set([
...sshIdentityFields,
...sshPasswordFields,
...sshPrivateKeyFields,
...sshExecutionFields,
]);
const sshIdentityRuntimeFields = runtimeFields.filter(([fieldName]) =>
sshIdentityFields.has(fieldName),
);
const sshPasswordRuntimeFields = runtimeFields.filter(([fieldName]) =>
sshPasswordFields.has(fieldName),
);
const sshPrivateKeyRuntimeFields = runtimeFields.filter(([fieldName]) =>
sshPrivateKeyFields.has(fieldName),
);
const sshExecutionRuntimeFields = runtimeFields.filter(([fieldName]) =>
sshExecutionFields.has(fieldName),
);
const remainingRuntimeFields = runtimeFields.filter(
([fieldName]) => !(isSshProvider && sshSharedFields.has(fieldName)),
);
return (
<>
@@ -378,6 +542,7 @@ function AdminSandboxSettings() {
<div className="ml-auto flex items-center gap-4">
<Button
type="button"
onClick={handleTestConnection}
disabled={testConnectionMutation.isPending}
variant="outline"
@@ -411,47 +576,350 @@ function AdminSandboxSettings() {
</div>
</header>
<div className="space-y-4">
{Object.entries(providerSchema).map(
([fieldName, schema]) => (
<div key={fieldName}>
<Label
htmlFor={fieldName}
className="text-text-primary"
>
{schema.required && (
<span className="text-state-error">
*
</span>
)}
{schema.label || fieldName}
{schema.description && (
<p className="text-xs text-text-secondary">
{schema.description}
</p>
)}
</Label>
<div className="mt-2">
{renderConfigField(fieldName, schema)}
<div className="space-y-6">
{(isSshProvider
? sshIdentityRuntimeFields.length > 0 ||
sshPasswordRuntimeFields.length > 0 ||
sshPrivateKeyRuntimeFields.length > 0 ||
sshExecutionRuntimeFields.length > 0 ||
remainingRuntimeFields.length > 0
: runtimeFields.length > 0) && (
<Collapsible defaultOpen>
<CollapsibleTrigger className="group w-full text-left">
<div className="flex items-center justify-between rounded-md border border-border-button px-4 py-3 transition-colors hover:bg-bg-card">
<h4 className="text-sm font-medium text-text-primary">
Runtime Settings
</h4>
<LucideChevronDown className="size-4 text-text-secondary transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
{schema.type === 'integer' &&
(schema.min !== undefined ||
schema.max !== undefined) && (
<p className="text-xs text-text-disabled mt-2">
{schema.min !== undefined &&
`Minimum: ${schema.min}`}
{schema.min !== undefined &&
schema.max !== undefined &&
' • '}
{schema.max !== undefined &&
`Maximum: ${schema.max}`}
<CollapsibleContent className="ml-4 mt-4 border-l border-border-button pl-4 space-y-4">
{isSshProvider && (
<>
{sshIdentityRuntimeFields.map(
([fieldName, schema]) => (
<div
key={fieldName}
className="space-y-2"
>
<Label
htmlFor={fieldName}
className="text-text-primary"
>
{isFieldRequired(
fieldName,
schema,
) && (
<span className="text-state-error">
*
</span>
)}
{schema.label ||
fieldName.replaceAll('_', ' ')}
</Label>
<div>
{renderConfigField(
fieldName,
schema,
)}
</div>
{schema.type === 'integer' &&
(schema.min !== undefined ||
schema.max !== undefined) && (
<p className="text-xs text-text-disabled">
{schema.min !== undefined &&
`Minimum: ${schema.min}`}
{schema.min !== undefined &&
schema.max !== undefined &&
' • '}
{schema.max !== undefined &&
`Maximum: ${schema.max}`}
</p>
)}
</div>
),
)}
{(sshPasswordRuntimeFields.length > 0 ||
sshPrivateKeyRuntimeFields.length >
0) && (
<div className="space-y-3 rounded-md border border-border-button p-4">
<div>
<h4 className="text-sm font-medium text-text-primary">
<span className="text-state-error">
*
</span>
Authentication
</h4>
<p className="text-xs text-text-secondary mt-1">
Choose one authentication method
for the SSH connection.
</p>
</div>
<Tabs
value={sshAuthMode}
onValueChange={(value) =>
handleSshAuthModeChange(
value as
| 'password'
| 'private_key',
)
}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="password">
Password
</TabsTrigger>
<TabsTrigger value="private_key">
Private Key
</TabsTrigger>
</TabsList>
<TabsContent
value="password"
className="space-y-4"
>
{sshPasswordRuntimeFields.map(
([fieldName, schema]) => (
<div
key={fieldName}
className="space-y-2"
>
<Label
htmlFor={fieldName}
className="text-text-primary"
>
{isFieldRequired(
fieldName,
schema,
) && (
<span className="text-state-error">
*
</span>
)}
{schema.label ||
fieldName.replaceAll(
'_',
' ',
)}
</Label>
<div>
{renderConfigField(
fieldName,
schema,
)}
</div>
</div>
),
)}
</TabsContent>
<TabsContent
value="private_key"
className="space-y-4"
>
{sshPrivateKeyRuntimeFields.map(
([fieldName, schema]) => (
<div
key={fieldName}
className="space-y-2"
>
<Label
htmlFor={fieldName}
className="text-text-primary"
>
{isFieldRequired(
fieldName,
schema,
) && (
<span className="text-state-error">
*
</span>
)}
{schema.label ||
fieldName.replaceAll(
'_',
' ',
)}
</Label>
<div>
{renderConfigField(
fieldName,
schema,
)}
</div>
{fieldName ===
'passphrase' && (
<p className="text-xs text-text-secondary">
Only required when the
private key itself is
encrypted.
</p>
)}
</div>
),
)}
</TabsContent>
</Tabs>
</div>
)}
{sshExecutionRuntimeFields.length > 0 && (
<div className="space-y-4 rounded-md border border-border-button p-4">
<div>
<h4 className="text-sm font-medium text-text-primary">
Execution
</h4>
<p className="text-xs text-text-secondary mt-1">
Configure the remote workspace and
language runtimes used on the SSH
host.
</p>
</div>
{sshExecutionRuntimeFields.map(
([fieldName, schema]) => (
<div
key={fieldName}
className="space-y-2"
>
<Label
htmlFor={fieldName}
className="text-text-primary"
>
{isFieldRequired(
fieldName,
schema,
) && (
<span className="text-state-error">
*
</span>
)}
{schema.label ||
fieldName.replaceAll(
'_',
' ',
)}
</Label>
<div>
{renderConfigField(
fieldName,
schema,
)}
</div>
</div>
),
)}
</div>
)}
</>
)}
{(isSshProvider
? remainingRuntimeFields
: runtimeFields
).map(([fieldName, schema]) => (
<div key={fieldName} className="space-y-2">
<Label
htmlFor={fieldName}
className="text-text-primary"
>
{isFieldRequired(fieldName, schema) && (
<span className="text-state-error">
*
</span>
)}
{schema.label ||
fieldName.replaceAll('_', ' ')}
</Label>
<div>
{renderConfigField(fieldName, schema)}
</div>
{schema.type === 'integer' &&
(schema.min !== undefined ||
schema.max !== undefined) && (
<p className="text-xs text-text-disabled">
{schema.min !== undefined &&
`Minimum: ${schema.min}`}
{schema.min !== undefined &&
schema.max !== undefined &&
' • '}
{schema.max !== undefined &&
`Maximum: ${schema.max}`}
</p>
)}
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
{deploymentFields.length > 0 && (
<Collapsible>
<CollapsibleTrigger className="group w-full text-left">
<div className="flex items-center justify-between rounded-md border border-border-button px-4 py-3 transition-colors hover:bg-bg-card">
<div>
<h4 className="text-sm font-medium">
Deployment Defaults
</h4>
<p className="text-xs text-text-secondary mt-1">
Read-only values loaded from the current
environment for the default
executor-manager deployment.
</p>
)}
</div>
),
</div>
<LucideChevronDown className="size-4 text-text-secondary transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent className="ml-4 mt-4 border-l border-border-button pl-4 space-y-4">
{deploymentFields.map(
([fieldName, schema]) => (
<div
key={fieldName}
className="space-y-2"
>
<Label
htmlFor={fieldName}
className="text-text-primary"
>
{schema.label ||
fieldName.replaceAll('_', ' ')}
</Label>
<div>
{renderConfigField(fieldName, schema)}
</div>
{schema.type === 'integer' &&
(schema.min !== undefined ||
schema.max !== undefined) && (
<p className="text-xs text-text-disabled">
{schema.min !== undefined &&
`Minimum: ${schema.min}`}
{schema.min !== undefined &&
schema.max !== undefined &&
' • '}
{schema.max !== undefined &&
`Maximum: ${schema.max}`}
</p>
)}
</div>
),
)}
</CollapsibleContent>
</Collapsible>
)}
</div>
</article>

View File

@@ -180,6 +180,9 @@ declare namespace AdminService {
label?: string;
placeholder?: string;
description?: string;
multiline?: boolean;
readonly?: boolean;
scope?: 'runtime' | 'deployment';
};
export type SandboxConfigStringField = SandboxConfigFieldBase & {