Add test/prod environments for databases
- Migration 010: test_* columns on databases table, environment column on request_logs - DatabasePoolManager: dual-pool strategy (prod + test), getPool(id, env) with fallback - All executors (SQL, Script, AQL): environment param threaded through execution paths - dynamicApiController: X-Environment header detection, environment in logging - databaseManagementController: CRUD for test credentials, testConnection with ?env=test - Frontend: test env form in DatabaseModal, env toggle in EndpointEditor test panel Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,7 @@ export default function EndpointEditor() {
|
||||
const [testResult, setTestResult] = useState<QueryTestResult | null>(null);
|
||||
const [activeResultTab, setActiveResultTab] = useState<'data' | 'logs' | 'queries'>('data');
|
||||
const [curlApiKey, setCurlApiKey] = useState('');
|
||||
const [testEnvironment, setTestEnvironment] = useState<'test' | 'prod'>('test');
|
||||
|
||||
const selectedDatabase = databases?.find(db => db.id === formData.database_id);
|
||||
const isAqlDatabase = selectedDatabase?.type === 'aql';
|
||||
@@ -189,6 +190,7 @@ export default function EndpointEditor() {
|
||||
aql_query_params: typeof formData.aql_query_params === 'string' ? {} : formData.aql_query_params || {},
|
||||
parameters: paramValues,
|
||||
endpoint_parameters: formData.parameters,
|
||||
environment: testEnvironment,
|
||||
} as any);
|
||||
} else if (formData.execution_type === 'script') {
|
||||
const scriptQueries = formData.script_queries || [];
|
||||
@@ -201,6 +203,7 @@ export default function EndpointEditor() {
|
||||
script_queries: scriptQueries,
|
||||
parameters: paramValues,
|
||||
endpoint_parameters: formData.parameters,
|
||||
environment: testEnvironment,
|
||||
});
|
||||
} else {
|
||||
return endpointsApi.test({
|
||||
@@ -209,6 +212,7 @@ export default function EndpointEditor() {
|
||||
sql_query: formData.sql_query || '',
|
||||
parameters: paramValues,
|
||||
endpoint_parameters: formData.parameters,
|
||||
environment: testEnvironment,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -901,8 +905,38 @@ export default function EndpointEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test button */}
|
||||
<div className="card p-4">
|
||||
{/* Environment toggle + Test button */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-gray-500">Среда:</span>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTestEnvironment('test')}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
testEnvironment === 'test'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTestEnvironment('prod')}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
testEnvironment === 'prod'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Prod
|
||||
</button>
|
||||
</div>
|
||||
{selectedDatabase && !selectedDatabase.has_test_env && testEnvironment === 'test' && (
|
||||
<span className="text-xs text-amber-600">Test env не настроена — fallback на prod</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => testMutation.mutate()}
|
||||
@@ -915,10 +949,14 @@ export default function EndpointEditor() {
|
||||
: !formData.script_code
|
||||
)
|
||||
}
|
||||
className="btn btn-primary w-full flex items-center justify-center gap-2"
|
||||
className={`btn w-full flex items-center justify-center gap-2 ${
|
||||
testEnvironment === 'test'
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'btn-primary'
|
||||
}`}
|
||||
>
|
||||
<Play size={18} />
|
||||
{testMutation.isPending ? 'Тестирование...' : 'Тест запроса'}
|
||||
{testMutation.isPending ? 'Тестирование...' : `Тест (${testEnvironment})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -284,13 +284,13 @@ function DatabasesSubTab() {
|
||||
onError: () => toast.error('Не удалось удалить базу данных'),
|
||||
});
|
||||
|
||||
const testConnection = async (databaseId: string) => {
|
||||
const testConnection = async (databaseId: string, env?: 'prod' | 'test') => {
|
||||
try {
|
||||
const { data } = await dbManagementApi.test(databaseId);
|
||||
const { data } = await dbManagementApi.test(databaseId, env);
|
||||
if (data.success) {
|
||||
toast.success('Подключение успешно!');
|
||||
toast.success(env === 'test' ? 'Test Env: подключение успешно!' : 'Подключение успешно!');
|
||||
} else {
|
||||
toast.error('Ошибка подключения');
|
||||
toast.error(env === 'test' ? 'Test Env: ошибка подключения' : 'Ошибка подключения');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Ошибка тестирования подключения');
|
||||
@@ -350,6 +350,9 @@ function DatabasesSubTab() {
|
||||
{db.is_active && (
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Активна</span>
|
||||
)}
|
||||
{db.has_test_env && (
|
||||
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded">Test Env</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-gray-600 ml-8">
|
||||
{db.type === 'aql' ? (
|
||||
@@ -375,6 +378,14 @@ function DatabasesSubTab() {
|
||||
>
|
||||
Тест
|
||||
</button>
|
||||
{db.has_test_env && (
|
||||
<button
|
||||
onClick={() => testConnection(db.id, 'test')}
|
||||
className="btn text-sm bg-orange-50 text-orange-700 border border-orange-200 hover:bg-orange-100"
|
||||
>
|
||||
Тест (Test Env)
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleEdit(db)}
|
||||
className="btn btn-secondary text-sm flex items-center gap-1"
|
||||
@@ -431,6 +442,7 @@ function DatabaseModal({
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showTestEnv, setShowTestEnv] = useState(database?.has_test_env || false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: database?.name || '',
|
||||
type: database?.type || 'postgresql',
|
||||
@@ -446,18 +458,30 @@ function DatabaseModal({
|
||||
aql_auth_type: database?.aql_auth_type || 'basic',
|
||||
aql_auth_value: database?.aql_auth_value || '',
|
||||
aql_headers: database?.aql_headers || {},
|
||||
// Test environment
|
||||
has_test_env: database?.has_test_env || false,
|
||||
test_host: database?.test_host || '',
|
||||
test_port: database?.test_port || 5432,
|
||||
test_database_name: database?.test_database_name || '',
|
||||
test_username: database?.test_username || '',
|
||||
test_password: '',
|
||||
test_ssl: database?.test_ssl || false,
|
||||
test_aql_base_url: database?.test_aql_base_url || '',
|
||||
test_aql_auth_value: '',
|
||||
test_aql_headers: database?.test_aql_headers || {},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
const payload = { ...data };
|
||||
// Если редактируем и пароль пустой, удаляем его из payload
|
||||
if (database && !payload.password) {
|
||||
delete payload.password;
|
||||
}
|
||||
// Для AQL: если редактируем и auth_value пустой, удаляем его
|
||||
if (database && data.type === 'aql' && !payload.aql_auth_value) {
|
||||
delete payload.aql_auth_value;
|
||||
if (database && !payload.password) delete payload.password;
|
||||
if (database && data.type === 'aql' && !payload.aql_auth_value) delete payload.aql_auth_value;
|
||||
if (database && !payload.test_password) delete payload.test_password;
|
||||
if (database && !payload.test_aql_auth_value) delete payload.test_aql_auth_value;
|
||||
if (!payload.has_test_env) {
|
||||
delete payload.test_host; delete payload.test_port; delete payload.test_database_name;
|
||||
delete payload.test_username; delete payload.test_password; delete payload.test_ssl;
|
||||
delete payload.test_aql_base_url; delete payload.test_aql_auth_value; delete payload.test_aql_headers;
|
||||
}
|
||||
return database ? dbManagementApi.update(database.id, payload) : dbManagementApi.create(payload);
|
||||
},
|
||||
@@ -693,6 +717,123 @@ function DatabaseModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Test Environment Section */}
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = !showTestEnv;
|
||||
setShowTestEnv(next);
|
||||
setFormData({ ...formData, has_test_env: next });
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
<span className={`transform transition-transform ${showTestEnv ? 'rotate-90' : ''}`}>▶</span>
|
||||
Тестовая среда
|
||||
{formData.has_test_env && (
|
||||
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded">Включена</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showTestEnv && (
|
||||
<div className="mt-4 space-y-4 pl-4 border-l-2 border-orange-200">
|
||||
<p className="text-xs text-gray-500">
|
||||
Альтернативные креды для тестирования endpoint-ов. При тестировании запросы пойдут на эту БД вместо продовой.
|
||||
</p>
|
||||
|
||||
{formData.type === 'aql' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Test AQL Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.test_aql_base_url}
|
||||
onChange={(e) => setFormData({ ...formData, test_aql_base_url: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="http://test-api.example.com/api/rest/v1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Test Auth Value</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.test_aql_auth_value}
|
||||
onChange={(e) => setFormData({ ...formData, test_aql_auth_value: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder={database ? '(не менять)' : 'Значение'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Test Host</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.test_host}
|
||||
onChange={(e) => setFormData({ ...formData, test_host: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="test-db-host"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Test Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.test_port}
|
||||
onChange={(e) => setFormData({ ...formData, test_port: parseInt(e.target.value) })}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Test DB Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.test_database_name}
|
||||
onChange={(e) => setFormData({ ...formData, test_database_name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="test_database"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Test User</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.test_username}
|
||||
onChange={(e) => setFormData({ ...formData, test_username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="test_user"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Test Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.test_password}
|
||||
onChange={(e) => setFormData({ ...formData, test_password: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder={database ? '(не менять)' : 'Пароль'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.test_ssl}
|
||||
onChange={(e) => setFormData({ ...formData, test_ssl: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Test SSL</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
|
||||
@@ -86,8 +86,8 @@ export const dbManagementApi = {
|
||||
delete: (id: string) =>
|
||||
api.delete(`/db-management/${id}`),
|
||||
|
||||
test: (id: string) =>
|
||||
api.get<{ success: boolean; message: string }>(`/db-management/${id}/test`),
|
||||
test: (id: string, env?: 'prod' | 'test') =>
|
||||
api.get<{ success: boolean; message: string }>(`/db-management/${id}/test${env === 'test' ? '?env=test' : ''}`),
|
||||
};
|
||||
|
||||
// Endpoints API
|
||||
@@ -120,6 +120,7 @@ export const endpointsApi = {
|
||||
aql_endpoint?: string;
|
||||
aql_body?: string;
|
||||
aql_query_params?: Record<string, string>;
|
||||
environment?: 'prod' | 'test';
|
||||
}) =>
|
||||
api.post<QueryTestResult>('/endpoints/test', data),
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ export interface Database {
|
||||
aql_auth_type?: 'basic' | 'bearer' | 'custom';
|
||||
aql_auth_value?: string;
|
||||
aql_headers?: Record<string, string>;
|
||||
// Test environment
|
||||
has_test_env?: boolean;
|
||||
test_host?: string;
|
||||
test_port?: number;
|
||||
test_database_name?: string;
|
||||
test_username?: string;
|
||||
test_ssl?: boolean;
|
||||
test_aql_base_url?: string;
|
||||
test_aql_headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
|
||||
Reference in New Issue
Block a user