Add test/prod environments and endpoint versioning

Phase 1: Test/Prod Database Configurations
- Migration 010: test_* columns on databases table, environment column on request_logs
- DatabasePoolManager: dual-pool strategy (prod + test), getPool(id, env) with fallback
- SqlExecutor, ScriptExecutor, IsolatedScriptExecutor, AqlExecutor: environment param threaded through all 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

Phase 2: Endpoint Versioning
- Migration 011: endpoint_versions table with full snapshots, backfill v1 for existing endpoints
- VersionService: createVersion, saveDraft, publishVersion, rollbackToVersion, getHistory
- endpointController: auto-versioning on update, 6 new version management handlers
- dynamicApiController: draft serving when environment=test and draft exists
- Frontend: Save Draft button, version history panel with publish/rollback actions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:04:11 +03:00
parent c918f34595
commit 801d0cce5f
19 changed files with 1263 additions and 223 deletions

View File

@@ -1,9 +1,9 @@
import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { endpointsApi, foldersApi, databasesApi } from '@/services/api';
import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution } from '@/types';
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal } from 'lucide-react';
import { endpointsApi, foldersApi, databasesApi, versionsApi } from '@/services/api';
import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution, EndpointVersion } from '@/types';
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal, History, RotateCcw, Upload } from 'lucide-react';
import toast from 'react-hot-toast';
import SqlEditor from '@/components/SqlEditor';
import CodeEditor from '@/components/CodeEditor';
@@ -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,
});
}
},
@@ -230,6 +234,43 @@ export default function EndpointEditor() {
},
});
// Version history
const { data: versions, refetch: refetchVersions } = useQuery({
queryKey: ['versions', id],
queryFn: () => versionsApi.getHistory(id!).then(res => res.data),
enabled: isEditing,
});
const [showVersionHistory, setShowVersionHistory] = useState(false);
const draftMutation = useMutation({
mutationFn: () => versionsApi.saveDraft(id!, { ...formData, change_message: undefined }),
onSuccess: () => {
toast.success('Draft saved');
refetchVersions();
},
onError: () => toast.error('Failed to save draft'),
});
const publishVersionMutation = useMutation({
mutationFn: (versionId: string) => versionsApi.publish(id!, versionId),
onSuccess: () => {
toast.success('Version published');
queryClient.invalidateQueries({ queryKey: ['endpoint', id] });
refetchVersions();
},
onError: () => toast.error('Failed to publish version'),
});
const rollbackMutation = useMutation({
mutationFn: (versionId: string) => versionsApi.rollback(id!, versionId),
onSuccess: () => {
toast.success('Rolled back');
queryClient.invalidateQueries({ queryKey: ['endpoint', id] });
refetchVersions();
},
onError: () => toast.error('Failed to rollback'),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
let parsedSchema = null;
@@ -841,13 +882,23 @@ export default function EndpointEditor() {
<button type="button" onClick={() => navigate(-1)} className="btn btn-secondary">
Отмена
</button>
{isEditing && (
<button
type="button"
disabled={draftMutation?.isPending}
className="btn bg-orange-50 text-orange-700 border border-orange-200 hover:bg-orange-100"
onClick={() => draftMutation?.mutate()}
>
{draftMutation?.isPending ? 'Сохранение...' : 'Save Draft'}
</button>
)}
<button
type="submit"
disabled={saveMutation.isPending}
className="btn btn-secondary"
onClick={() => setSaveAndStay(true)}
>
{saveMutation.isPending && saveAndStay ? 'Сохранение...' : 'Сохранить'}
{saveMutation.isPending && saveAndStay ? 'Публикация...' : 'Publish'}
</button>
<button
type="submit"
@@ -855,7 +906,7 @@ export default function EndpointEditor() {
className="btn btn-primary"
onClick={() => setSaveAndStay(false)}
>
{saveMutation.isPending && !saveAndStay ? 'Сохранение...' : 'Сохранить и выйти'}
{saveMutation.isPending && !saveAndStay ? 'Публикация...' : 'Publish & Exit'}
</button>
</div>
</div>
@@ -901,8 +952,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 +996,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>
@@ -952,6 +1037,68 @@ export default function EndpointEditor() {
</pre>
</div>
{/* Version History */}
{isEditing && (
<div className="card p-4">
<button
type="button"
onClick={() => setShowVersionHistory(!showVersionHistory)}
className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 w-full"
>
<History size={16} />
<span>Version History</span>
<span className="text-xs text-gray-400 ml-1">({versions?.length || 0})</span>
<span className="ml-auto">{showVersionHistory ? <ChevronUp size={14} /> : <ChevronDown size={14} />}</span>
</button>
{showVersionHistory && versions && (
<div className="mt-3 space-y-2 max-h-80 overflow-y-auto">
{versions.map((v: EndpointVersion) => (
<div key={v.id} className="flex items-center gap-2 text-xs border border-gray-100 rounded-lg p-2">
<span className="font-mono font-bold text-gray-700">v{v.version_number}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
v.status === 'published' ? 'bg-green-100 text-green-700' :
v.status === 'draft' ? 'bg-orange-100 text-orange-700' :
'bg-gray-100 text-gray-500'
}`}>
{v.status}
</span>
<span className="text-gray-400 truncate flex-1" title={v.change_message || ''}>
{v.change_message || '-'}
</span>
<span className="text-gray-400 whitespace-nowrap">
{new Date(v.created_at).toLocaleDateString('ru-RU')}
</span>
{v.status === 'draft' && (
<button
type="button"
onClick={() => publishVersionMutation.mutate(v.id)}
className="p-1 hover:bg-green-50 rounded text-green-600"
title="Publish"
>
<Upload size={12} />
</button>
)}
{v.status !== 'draft' && (
<button
type="button"
onClick={() => rollbackMutation.mutate(v.id)}
className="p-1 hover:bg-blue-50 rounded text-blue-600"
title="Rollback to this version"
>
<RotateCcw size={12} />
</button>
)}
</div>
))}
{versions.length === 0 && (
<p className="text-xs text-gray-400 text-center py-2">No versions yet</p>
)}
</div>
)}
</div>
)}
{/* Test results */}
{testResult && (
<div className="card overflow-hidden">

View File

@@ -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' : ''}`}>&#9654;</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">
Отмена

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse } from '@/types';
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse, EndpointVersion } from '@/types';
const api = axios.create({
baseURL: '/api',
@@ -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),
@@ -142,6 +143,27 @@ export const endpointsApi = {
api.post<Endpoint>('/endpoints/import', data),
};
// Versions API
export const versionsApi = {
getHistory: (endpointId: string) =>
api.get<EndpointVersion[]>(`/endpoints/${endpointId}/versions`),
getVersion: (endpointId: string, versionId: string) =>
api.get<EndpointVersion>(`/endpoints/${endpointId}/versions/${versionId}`),
publish: (endpointId: string, versionId: string) =>
api.post(`/endpoints/${endpointId}/versions/${versionId}/publish`),
rollback: (endpointId: string, versionId: string) =>
api.post(`/endpoints/${endpointId}/versions/${versionId}/rollback`),
saveDraft: (endpointId: string, data: any) =>
api.post<EndpointVersion>(`/endpoints/${endpointId}/draft`, data),
getDraft: (endpointId: string) =>
api.get<EndpointVersion>(`/endpoints/${endpointId}/draft`),
};
// Folders API
export const foldersApi = {
getAll: () =>

View File

@@ -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 {
@@ -125,6 +134,39 @@ export interface QueryTestResult {
processedQuery?: string;
}
export type VersionStatus = 'draft' | 'published' | 'archived';
export interface EndpointVersion {
id: string;
endpoint_id: string;
version_number: number;
name: string;
description: string;
method: string;
path: string;
database_id: string;
sql_query: string;
parameters: EndpointParameter[];
execution_type: string;
script_language?: string;
script_code?: string;
script_queries?: ScriptQuery[];
aql_method?: string;
aql_endpoint?: string;
aql_body?: string;
aql_query_params?: Record<string, string>;
is_public: boolean;
enable_logging: boolean;
detailed_response: boolean;
response_schema?: object | null;
status: VersionStatus;
change_message: string | null;
user_id: string;
user_name?: string;
content_hash: string | null;
created_at: string;
}
export interface ImportPreviewDatabase {
name: string;
type: string;