refactor: update admin ui (#13280)

### What problem does this PR solve?

Update for Admin UI:
- Update file picker input in **Registration whitelist** > **Import from
Excel** modal
- Modify DOM structure of **Sandbox Settings** and move several
hardcoded texts into translation files

### Type of change

- [x] Refactoring
This commit is contained in:
Jimmy Ben Klieve
2026-02-28 19:21:51 +08:00
committed by GitHub
parent e62552d482
commit 7e0dd906f2
5 changed files with 363 additions and 188 deletions

View File

@@ -389,7 +389,7 @@ export const AudioButton = ({
<div
className="
absolute inset-0 rounded-full border-2 border-state-success
animate-ping duration-[4s] opacity-75 pointer-events-none"
animate-ping opacity-75 pointer-events-none"
/>
)}

View File

@@ -1010,22 +1010,18 @@ Example: Virtual Hosted Style`,
'Connect to your SeaFile server to synchronise files and documents from your libraries.',
seafileUrlTip:
'The full URL of your SeaFile server including the protocol. Example: https://seafile.example.com - Do not include a trailing slash or any path after the domain.',
seafileAccountScopeTip:
seafileAccountScopeTip:
'Syncs all libraries visible to the Account API Token below.',
seafileTokenPanelHeading:
'Provide one of these authentication methods:',
seafileTokenPanelAccountBullet:
'- grants access to all your libraries.',
seafileTokenPanelLibraryBullet:
'— scoped to a single library only (more secure).',
seafileValidationAccountTokenRequired:
seafileTokenPanelHeading: 'Provide one of these authentication methods:',
seafileTokenPanelAccountBullet: '- grants access to all your libraries.',
seafileTokenPanelLibraryBullet:
'— scoped to a single library only (more secure).',
seafileValidationAccountTokenRequired:
'Account API Token is required for Entire Account scope',
seafileValidationTokenRequired:
seafileValidationTokenRequired:
'Provide either an Account API Token or a Library Token',
seafileValidationLibraryIdRequired:
'Library ID is required',
seafileValidationDirectoryPathRequired:
'Directory Path is required',
seafileValidationLibraryIdRequired: 'Library ID is required',
seafileValidationDirectoryPathRequired: 'Directory Path is required',
seafileSyncScopeTip:
'Controls what gets synchronised: ' +
'(1) Entire Account - Syncs all libraries your token has access to. Requires an Account API Token. ' +
@@ -2533,6 +2529,39 @@ Important structured information may include: names, dates, locations, events, k
roles: 'Roles',
monitoring: 'Monitoring',
sandboxSettingsPage: {
description:
'Configure your code execution sandbox provider. The sandbox is used by the Code component in agents.',
providerSelection: 'Provider selection',
providerSelectionDescription:
'Choose a sandbox provider for code execution',
namedProviderConfiguration: '{{name}} configuration',
namedProviderConfigurationDescription:
'Configure the connection settings for {{name}}.',
saveConfiguration: 'Save configuration',
saving: 'Saving...',
testConnectionResultModal: {
title: 'Connection test result',
testing: 'Testing connection to sandbox provider...',
success: 'Successfully connected to sandbox provider',
failed: 'Failed to connect to sandbox provider',
exitCode: 'Exit code',
executionTime: 'Execution time',
stdout: 'Standard output',
stderr: 'Error output / stack trace',
},
testConnection: 'Test connection',
testing: 'Testing...',
},
selectFile: 'Select file',
noFileSelected: 'No file selected',
back: 'Back',
active: 'Active',
inactive: 'Inactive',

View File

@@ -8,7 +8,7 @@ import {
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback, useId, useMemo } from 'react';
import { useCallback, useId, useMemo, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { z } from 'zod';
@@ -30,6 +30,7 @@ export const ImportExcelForm = ({
onSubmit = () => {},
}: ImportExcelFormProps) => {
const { t } = useTranslation();
const filePickerRef = useRef<HTMLInputElement | null>(null);
return (
<Form {...form}>
@@ -50,17 +51,47 @@ export const ImportExcelForm = ({
</FormLabel>
<FormControl>
<Input
type="file"
accept=".xlsx"
className="mt-2 px-3 h-10 bg-bg-input border-border-button file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-bg-accent file:text-text-primary hover:file:bg-bg-accent/80"
onChange={(e) => {
const files = e.target.files;
onChange(files?.[0]);
<button
type="button"
className="
group
w-full h-10 px-2 py-1 flex items-center gap-4 bg-bg-input
rounded-md border-0.5 border-border-button"
onClick={() => {
filePickerRef.current?.click();
}}
{...field}
/>
>
<div
className="
h-6 px-2 py-1 flex items-center justify-center text-sm text-text-primary rounded
bg-bg-input border-0.5 border-border-default transition-colors
group-hover:bg-border-button group-focus-visible:bg-border-button"
>
{t('admin.selectFile')}
</div>
<span className="text-sm text-text-secondary">
{value ? value.name : t('admin.noFileSelected')}
</span>
</button>
</FormControl>
<Input
type="file"
accept=".xlsx"
className="hidden"
tabIndex={-1}
onChange={(e) => {
const files = e.target.files;
onChange(files?.[0]);
}}
{...field}
ref={(ref) => {
filePickerRef.current = ref;
field.ref(ref);
}}
/>
<FormMessage />
</FormItem>
)}

View File

@@ -5,7 +5,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
LucideCloud,
LucideLink,
LucideLoader2,
LucideSave,
LucideServer,
LucideZap,
} from 'lucide-react';
@@ -39,7 +41,10 @@ import {
testSandboxConnection,
} from '@/services/admin-service';
import Spotlight from '@/components/spotlight';
import message from '@/components/ui/message';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { ScrollArea } from '@/components/ui/scroll-area';
// Provider icons mapping
const PROVIDER_ICONS: Record<string, React.ElementType> = {
@@ -74,7 +79,11 @@ function AdminSandboxSettings() {
});
// Fetch current config
const { data: currentConfig, isLoading: configLoading } = useQuery({
const {
data: currentConfig,
isLoading: configLoading,
refetch: refetchConfig,
} = useQuery({
queryKey: ['admin/getSandboxConfig'],
queryFn: async () => (await getSandboxConfig()).data.data,
});
@@ -95,7 +104,8 @@ function AdminSandboxSettings() {
}) => (await setSandboxConfig(params)).data,
onSuccess: () => {
message.success('Sandbox configuration updated successfully');
queryClient.invalidateQueries({ queryKey: ['admin/getSandboxConfig'] });
// queryClient.invalidateQueries({ queryKey: ['admin/getSandboxConfig'] });
refetchConfig();
},
onError: (error: Error) => {
message.error(`Failed to update configuration: ${error.message}`);
@@ -186,15 +196,15 @@ function AdminSandboxSettings() {
schema: AdminService.SandboxConfigField,
) => {
const value = configValues[fieldName] ?? schema.default ?? '';
const isSecret = schema.secret ?? false;
switch (schema.type) {
case 'string':
if (isSecret) {
if (schema.secret) {
return (
<Input
type="password"
id={fieldName}
className="h-10"
placeholder={schema.placeholder}
value={value as string}
onChange={(e) =>
@@ -226,6 +236,7 @@ function AdminSandboxSettings() {
min={schema.min}
max={schema.max}
value={value as number}
className="h-10"
onChange={(e) =>
handleConfigValueChange(fieldName, parseInt(e.target.value) || 0)
}
@@ -248,171 +259,233 @@ function AdminSandboxSettings() {
}
};
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>
<>
<Card className="!shadow-none relative h-full bg-transparent overflow-hidden">
<Spotlight />
{/* 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>
<ScrollArea className="size-full">
<CardHeader className="space-y-0">
<CardTitle className="leading-10">
{t('admin.sandboxSettings')}
</CardTitle>
<CardDescription className="text-text-secondary">
{t('admin.sandboxSettingsPage.description')}
</CardDescription>
</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>
{providersLoading || configLoading ? (
<div className="flex items-center justify-center h-96"></div>
) : (
<>
{/* Provider Selection */}
<Card className="!shadow-none bg-transparent">
<CardHeader>
<CardTitle className="text-xl leading-none">
{t('admin.sandboxSettingsPage.providerSelection')}
</CardTitle>
<CardDescription className="text-text-secondary">
{t(
'admin.sandboxSettingsPage.providerSelectionDescription',
)}
</CardDescription>
<RadioGroup
className="!mt-4 max-w-7xl grid grid-cols-1 xl:grid-cols-3 gap-4"
value={selectedProvider}
onValueChange={handleProviderChange}
>
{providers.map((provider) => {
const Icon =
PROVIDER_ICONS[provider.id] || LucideServer;
return (
<label
key={provider.id}
tabIndex={0}
className="
group relative rounded-lg border-0.5 border-border-button p-4 cursor-pointer transition-all
hover:bg-bg-card focus-visible:bg-bg-card
has-[[aria-checked=true]]:bg-bg-card
has-[[aria-checked=true]]:ring-1 has-[[aria-checked=true]]:ring-border-default"
>
<RadioGroupItem
key={provider.id}
className="hidden"
value={provider.id}
/>
<div className="flex items-start gap-2">
<Icon className="size-5 transition-colors text-text-primary group-has-[[aria-checked=true]]:text-accent-primary" />
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium">
{provider.name}
</h4>
<p className="text-xs text-text-secondary 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 bg-bg-card text-text-secondary"
>
{tag}
</span>
))}
</div>
</div>
</div>
</label>
);
})}
</RadioGroup>
</CardHeader>
{/* Provider Configuration */}
{selectedProvider && selectedProviderData && (
<CardContent className="pt-6 border-t-0.5 border-border-button">
<form onSubmit={handleSave}>
<article>
<header className="mb-6 flex items-center gap-4">
<div>
<h3 className="flex items-center gap-2">
<ProviderIcon className="size-[1em]" />
{t(
'admin.sandboxSettingsPage.namedProviderConfiguration',
{ name: selectedProviderData.name },
)}
</h3>
<p className="text-sm text-text-secondary">
{t(
'admin.sandboxSettingsPage.namedProviderConfigurationDescription',
{ name: selectedProviderData.name },
)}
</p>
</div>
<div className="ml-auto flex items-center gap-4">
<Button
onClick={handleTestConnection}
disabled={testConnectionMutation.isPending}
variant="outline"
>
{testConnectionMutation.isPending ? (
<>
<LucideLoader2 className="w-4 h-4 mr-2 animate-spin" />
{t('admin.sandboxSettingsPage.testing')}
</>
) : (
<>
<LucideLink />
{t(
'admin.sandboxSettingsPage.testConnection',
)}
</>
)}
</Button>
<Button
type="submit"
disabled={setConfigMutation.isPending}
>
{setConfigMutation.isPending ? (
<LucideLoader2 className="animate-spin" />
) : (
<LucideSave />
)}
{t('common.save')}
</Button>
</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>
{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}`}
</p>
)}
</div>
),
)}
</div>
</article>
</form>
</CardContent>
)}
{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>
</Card>
</>
)}
</CardContent>
</Card>
)}
</ScrollArea>
</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>
<DialogTitle>
{t('admin.sandboxSettingsPage.testConnectionResultModal.title')}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{testResult === null
? t('admin.sandboxSettingsPage.testConnectionResultModal.testing')
: testResult.success
? t(
'admin.sandboxSettingsPage.testConnectionResultModal.success',
)
: t(
'admin.sandboxSettingsPage.testConnectionResultModal.failed',
)}
</DialogDescription>
{testResult === null ? (
<div className="flex items-center justify-center py-8">
<LucideLoader2 className="w-8 h-8 animate-spin text-muted-foreground" />
@@ -438,11 +511,21 @@ function AdminSandboxSettings() {
{/* 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>{' '}
<span className="font-medium">
{t(
'admin.sandboxSettingsPage.testConnectionResultModal.exitCode',
)}
:
</span>{' '}
{testResult.details.exit_code}
</div>
<div className="p-2 bg-muted rounded">
<span className="font-medium">Execution Time:</span>{' '}
<span className="font-medium">
{t(
'admin.sandboxSettingsPage.testConnectionResultModal.executionTime',
)}
:
</span>{' '}
{testResult.details.execution_time?.toFixed(2)}s
</div>
</div>
@@ -451,7 +534,10 @@ function AdminSandboxSettings() {
{testResult.details.stdout && (
<div className="p-3 bg-muted rounded">
<p className="text-xs font-medium mb-2 text-muted-foreground">
Standard Output:
{t(
'admin.sandboxSettingsPage.testConnectionResultModal.stdout',
)}
:
</p>
<pre className="text-xs whitespace-pre-wrap break-words font-mono">
{testResult.details.stdout}
@@ -463,7 +549,10 @@ function AdminSandboxSettings() {
{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:
{t(
'admin.sandboxSettingsPage.testConnectionResultModal.stderr',
)}
:
</p>
<pre className="text-xs whitespace-pre-wrap break-words font-mono text-red-900 dark:text-red-100">
{testResult.details.stderr}
@@ -475,11 +564,13 @@ function AdminSandboxSettings() {
</div>
)}
<DialogFooter>
<Button onClick={() => setTestModalOpen(false)}>Close</Button>
<Button onClick={() => setTestModalOpen(false)}>
{t('admin.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
);
}

View File

@@ -1,4 +1,4 @@
declare module AdminService {
declare namespace AdminService {
export type LoginData = {
access_token: string;
avatar: unknown;
@@ -175,18 +175,42 @@ declare module AdminService {
tags: string[];
};
export type SandboxConfigField = {
type: 'string' | 'integer' | 'boolean' | 'json';
export type SandboxConfigFieldBase = {
required?: boolean;
label?: string;
placeholder?: string;
default?: string | number | boolean;
min?: number;
max?: number;
description?: string;
};
export type SandboxConfigStringField = SandboxConfigFieldBase & {
type: 'string';
default?: string;
secret?: boolean;
};
export type SandboxConfigIntegerField = SandboxConfigFieldBase & {
type: 'integer';
default?: number;
min?: number;
max?: number;
};
export type SandboxConfigBooleanField = SandboxConfigFieldBase & {
type: 'boolean';
default?: boolean;
};
export type SandboxConfigJsonField = SandboxConfigFieldBase & {
type: 'json';
default?: unknown;
};
export type SandboxConfigField =
| SandboxConfigStringField
| SandboxConfigIntegerField
| SandboxConfigBooleanField
| SandboxConfigJsonField;
export type SandboxConfig = {
provider_type: string;
config: Record<string, unknown>;