2432 أسطر
122 KiB
JavaScript
2432 أسطر
122 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
postgresApi,
|
||
s3Api,
|
||
postgresToS3Api,
|
||
injectEnv,
|
||
getCurrentEnv,
|
||
healthCheck,
|
||
formatEnvVars,
|
||
copyToClipboard,
|
||
formatMigrationDetails,
|
||
formatFileSize,
|
||
formatPostgresConfig,
|
||
formatS3Config,
|
||
extractPostgresInfo,
|
||
extractS3Info,
|
||
buildPostgresConnectionString,
|
||
buildS3Url,
|
||
groupTablesBySchema,
|
||
groupObjectsByPrefix,
|
||
// إضافة دوال التقدم المفقودة
|
||
streamProgress,
|
||
streamProgressAsync,
|
||
createProgressStream,
|
||
formatProgressDisplay,
|
||
createProgressBar,
|
||
estimateRemainingTime,
|
||
formatTimeEstimate
|
||
} from './unifiedApi';
|
||
import './App.css';
|
||
|
||
function App() {
|
||
// ==================== PostgreSQL Source Configuration ====================
|
||
const [postgresConfig, setPostgresConfig] = useState({
|
||
host: '',
|
||
user: '',
|
||
password: '',
|
||
port: 5432,
|
||
database: '',
|
||
uri: ''
|
||
});
|
||
|
||
// ==================== PostgreSQL Destination Configuration ====================
|
||
const [destPostgresConfig, setDestPostgresConfig] = useState({
|
||
host: '',
|
||
user: '',
|
||
password: '',
|
||
port: 5432,
|
||
database: '',
|
||
uri: ''
|
||
});
|
||
|
||
// ==================== Progress Tracking States ====================
|
||
const [progressData, setProgressData] = useState(null);
|
||
const [progressStream, setProgressStream] = useState(null);
|
||
const [showProgressModal, setShowProgressModal] = useState(false);
|
||
const [progressHistory, setProgressHistory] = useState([]);
|
||
|
||
// ==================== Source S3 Configuration ====================
|
||
const [sourceS3Config, setSourceS3Config] = useState({
|
||
accessKeyId: '',
|
||
secretAccessKey: '',
|
||
region: 'us-east-1',
|
||
endpointUrl: '',
|
||
sessionToken: '',
|
||
bucket: '',
|
||
prefix: ''
|
||
});
|
||
|
||
// ==================== Destination S3 Configuration ====================
|
||
const [destS3Config, setDestS3Config] = useState({
|
||
accessKeyId: '',
|
||
secretAccessKey: '',
|
||
region: 'us-east-1',
|
||
endpointUrl: '',
|
||
sessionToken: '',
|
||
bucket: '',
|
||
prefix: ''
|
||
});
|
||
|
||
// ==================== PostgreSQL to S3 Configuration ====================
|
||
const [pgToS3Config, setPgToS3Config] = useState({
|
||
postgresUri: '',
|
||
s3Bucket: '',
|
||
s3Prefix: '',
|
||
compress: true,
|
||
format: 'csv'
|
||
});
|
||
|
||
// ==================== State Variables ====================
|
||
const [postgresSchemas, setPostgresSchemas] = useState([]);
|
||
const [postgresTables, setPostgresTables] = useState([]);
|
||
const [sourceBuckets, setSourceBuckets] = useState([]);
|
||
const [destBuckets, setDestBuckets] = useState([]);
|
||
const [sourceObjects, setSourceObjects] = useState([]);
|
||
const [destObjects, setDestObjects] = useState([]);
|
||
const [selectedPostgresDb, setSelectedPostgresDb] = useState('');
|
||
const [selectedSourceBucket, setSelectedSourceBucket] = useState('');
|
||
const [selectedDestBucket, setSelectedDestBucket] = useState('');
|
||
const [selectedPrefix, setSelectedPrefix] = useState('');
|
||
const [selectedSchemas, setSelectedSchemas] = useState([]);
|
||
const [selectedTables, setSelectedTables] = useState([]);
|
||
const [selectedObjects, setSelectedObjects] = useState([]);
|
||
const [migrations, setMigrations] = useState([]);
|
||
const [activeMigration, setActiveMigration] = useState(null);
|
||
const [migrationLogs, setMigrationLogs] = useState([]);
|
||
const [parsedPostgresUri, setParsedPostgresUri] = useState(null);
|
||
const [parsedS3Uri, setParsedS3Uri] = useState(null);
|
||
const [currentEnv, setCurrentEnv] = useState({});
|
||
const [loading, setLoading] = useState(false);
|
||
const [activeTab, setActiveTab] = useState('postgres');
|
||
const [envFormat, setEnvFormat] = useState('dotenv');
|
||
const [notifications, setNotifications] = useState([]);
|
||
const [postgresConnectionStatus, setPostgresConnectionStatus] = useState(null);
|
||
const [destPostgresConnectionStatus, setDestPostgresConnectionStatus] = useState(null);
|
||
const [sourceS3ConnectionStatus, setSourceS3ConnectionStatus] = useState(null);
|
||
const [destS3ConnectionStatus, setDestS3ConnectionStatus] = useState(null);
|
||
const [showSourceBuckets, setShowSourceBuckets] = useState(false);
|
||
const [showDestBuckets, setShowDestBuckets] = useState(false);
|
||
const [showPostgresSchemas, setShowPostgresSchemas] = useState(false);
|
||
const [migrationOptions, setMigrationOptions] = useState({
|
||
createDestBucket: true,
|
||
maxConcurrent: 5,
|
||
preserveMetadata: true,
|
||
storageClass: 'STANDARD',
|
||
includePatterns: '',
|
||
excludePatterns: ''
|
||
});
|
||
const [objectGroups, setObjectGroups] = useState({});
|
||
const [tableGroups, setTableGroups] = useState({});
|
||
const [totalSize, setTotalSize] = useState(0);
|
||
const [totalRows, setTotalRows] = useState(0);
|
||
|
||
// ==================== Notification System ====================
|
||
const addNotification = (message, type = 'info') => {
|
||
const id = Date.now();
|
||
setNotifications(prev => [...prev, { id, message, type }]);
|
||
setTimeout(() => {
|
||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||
}, 5000);
|
||
};
|
||
|
||
// ==================== Load Initial Data ====================
|
||
useEffect(() => {
|
||
loadCurrentEnv();
|
||
}, []);
|
||
|
||
const loadCurrentEnv = async () => {
|
||
try {
|
||
const result = await getCurrentEnv();
|
||
if (result.success) {
|
||
setCurrentEnv(result.environment_variables);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading current env:', error);
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// PostgreSQL Functions
|
||
// ============================================================================
|
||
|
||
const testPostgresConnection = async (isSource = true) => {
|
||
setLoading(true);
|
||
try {
|
||
const config = isSource ? postgresConfig : destPostgresConfig;
|
||
let uri = config.uri;
|
||
|
||
if (!uri && config.host && config.user && config.password && config.database) {
|
||
uri = `postgresql://${config.user}:${config.password}@${config.host}:${config.port}/${config.database}`;
|
||
}
|
||
|
||
const result = await postgresApi.testConnection({
|
||
useEnvVars: false,
|
||
uri
|
||
});
|
||
|
||
if (result.success) {
|
||
addNotification(`✅ PostgreSQL ${isSource ? 'source' : 'destination'} connection successful!`, 'success');
|
||
if (isSource) {
|
||
setPostgresConnectionStatus({
|
||
success: true,
|
||
host: config.host,
|
||
port: config.port,
|
||
version: result.version,
|
||
database: result.database
|
||
});
|
||
} else {
|
||
setDestPostgresConnectionStatus({
|
||
success: true,
|
||
host: config.host,
|
||
port: config.port,
|
||
version: result.version,
|
||
database: result.database
|
||
});
|
||
}
|
||
loadCurrentEnv();
|
||
} else {
|
||
addNotification(`❌ PostgreSQL ${isSource ? 'source' : 'destination'} connection failed: ${result.error}`, 'error');
|
||
if (isSource) {
|
||
setPostgresConnectionStatus({
|
||
success: false,
|
||
error: result.error,
|
||
diagnostic: result.diagnostic
|
||
});
|
||
} else {
|
||
setDestPostgresConnectionStatus({
|
||
success: false,
|
||
error: result.error,
|
||
diagnostic: result.diagnostic
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error testing PostgreSQL connection: ${error.message}`, 'error');
|
||
if (isSource) {
|
||
setPostgresConnectionStatus({
|
||
success: false,
|
||
error: error.message
|
||
});
|
||
} else {
|
||
setDestPostgresConnectionStatus({
|
||
success: false,
|
||
error: error.message
|
||
});
|
||
}
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const getPostgresSchemas = async (isSource = true) => {
|
||
const config = isSource ? postgresConfig : destPostgresConfig;
|
||
let uri = config.uri || buildPostgresConnectionString(
|
||
config.host,
|
||
config.database,
|
||
config.user,
|
||
config.port
|
||
);
|
||
|
||
if (!uri) {
|
||
addNotification('Please enter PostgreSQL connection details', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await postgresApi.getSchemas(uri);
|
||
if (result.success) {
|
||
setPostgresSchemas(result.schemas || []);
|
||
setShowPostgresSchemas(true);
|
||
addNotification(`✅ Found ${result.count} schema(s)`, 'success');
|
||
} else {
|
||
addNotification(`❌ Failed to get schemas: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error getting schemas: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const getPostgresTables = async (schema = '', isSource = true) => {
|
||
const config = isSource ? postgresConfig : destPostgresConfig;
|
||
let uri = config.uri || buildPostgresConnectionString(
|
||
config.host,
|
||
config.database,
|
||
config.user,
|
||
config.port
|
||
);
|
||
|
||
if (!uri) {
|
||
addNotification('Please enter PostgreSQL connection details', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await postgresApi.getTables(uri, schema);
|
||
if (result.success) {
|
||
setPostgresTables(result.tables || []);
|
||
|
||
const total = (result.tables || []).reduce((sum, table) => sum + (table.rows || 0), 0);
|
||
setTotalRows(total);
|
||
|
||
const groups = groupTablesBySchema(result.tables || []);
|
||
setTableGroups(groups);
|
||
|
||
addNotification(`✅ Found ${result.count} table(s)`, 'success');
|
||
} else {
|
||
addNotification(`❌ Failed to get tables: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error getting tables: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const parsePostgresUri = async (uri, isSource = true) => {
|
||
try {
|
||
const result = await postgresApi.parseUri(uri);
|
||
if (result.success) {
|
||
setParsedPostgresUri(result);
|
||
if (result.parsed) {
|
||
if (isSource) {
|
||
setPostgresConfig({
|
||
host: result.parsed.host || '',
|
||
user: result.parsed.user || '',
|
||
password: result.parsed.password || '',
|
||
port: result.parsed.port || 5432,
|
||
database: result.parsed.database || '',
|
||
uri: uri
|
||
});
|
||
} else {
|
||
setDestPostgresConfig({
|
||
host: result.parsed.host || '',
|
||
user: result.parsed.user || '',
|
||
password: result.parsed.password || '',
|
||
port: result.parsed.port || 5432,
|
||
database: result.parsed.database || '',
|
||
uri: uri
|
||
});
|
||
}
|
||
}
|
||
addNotification('✅ PostgreSQL URI parsed successfully', 'success');
|
||
} else {
|
||
setParsedPostgresUri(null);
|
||
addNotification(`❌ Failed to parse PostgreSQL URI: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error parsing PostgreSQL URI:', error);
|
||
addNotification(`❌ Error parsing PostgreSQL URI: ${error.message}`, 'error');
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// S3 Functions
|
||
// ============================================================================
|
||
|
||
const testSourceS3Connection = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await s3Api.testSourceConnection({
|
||
useEnvVars: false,
|
||
accessKeyId: sourceS3Config.accessKeyId,
|
||
secretAccessKey: sourceS3Config.secretAccessKey,
|
||
region: sourceS3Config.region,
|
||
endpointUrl: sourceS3Config.endpointUrl,
|
||
sessionToken: sourceS3Config.sessionToken
|
||
});
|
||
|
||
if (result.success) {
|
||
addNotification(`✅ Source S3 connection successful!`, 'success');
|
||
setSourceS3ConnectionStatus({
|
||
success: true,
|
||
endpoint: sourceS3Config.endpointUrl || 'AWS S3 (default)',
|
||
region: sourceS3Config.region,
|
||
bucketCount: result.bucket_count
|
||
});
|
||
loadCurrentEnv();
|
||
} else {
|
||
addNotification(`❌ Source S3 connection failed: ${result.error}`, 'error');
|
||
setSourceS3ConnectionStatus({
|
||
success: false,
|
||
error: result.error,
|
||
diagnostic: result.diagnostic
|
||
});
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error testing source S3 connection: ${error.message}`, 'error');
|
||
setSourceS3ConnectionStatus({
|
||
success: false,
|
||
error: error.message
|
||
});
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const testDestinationS3Connection = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await s3Api.testDestinationConnection({
|
||
useEnvVars: false,
|
||
accessKeyId: destS3Config.accessKeyId,
|
||
secretAccessKey: destS3Config.secretAccessKey,
|
||
region: destS3Config.region,
|
||
endpointUrl: destS3Config.endpointUrl,
|
||
sessionToken: destS3Config.sessionToken
|
||
});
|
||
|
||
if (result.success) {
|
||
addNotification(`✅ Destination S3 connection successful!`, 'success');
|
||
setDestS3ConnectionStatus({
|
||
success: true,
|
||
endpoint: destS3Config.endpointUrl || 'AWS S3 (default)',
|
||
region: destS3Config.region,
|
||
bucketCount: result.bucket_count
|
||
});
|
||
loadCurrentEnv();
|
||
} else {
|
||
addNotification(`❌ Destination S3 connection failed: ${result.error}`, 'error');
|
||
setDestS3ConnectionStatus({
|
||
success: false,
|
||
error: result.error,
|
||
diagnostic: result.diagnostic
|
||
});
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error testing destination S3 connection: ${error.message}`, 'error');
|
||
setDestS3ConnectionStatus({
|
||
success: false,
|
||
error: error.message
|
||
});
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const listSourceBuckets = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await s3Api.listSourceBuckets(
|
||
sourceS3Config.accessKeyId,
|
||
sourceS3Config.secretAccessKey,
|
||
sourceS3Config.region,
|
||
sourceS3Config.endpointUrl,
|
||
sourceS3Config.sessionToken
|
||
);
|
||
|
||
if (result.success) {
|
||
setSourceBuckets(result.buckets || []);
|
||
setShowSourceBuckets(true);
|
||
addNotification(`✅ Found ${result.count} source bucket(s)`, 'success');
|
||
} else {
|
||
addNotification(`❌ Failed to list source buckets: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error listing source buckets: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const listDestinationBuckets = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await s3Api.listDestinationBuckets(
|
||
destS3Config.accessKeyId,
|
||
destS3Config.secretAccessKey,
|
||
destS3Config.region,
|
||
destS3Config.endpointUrl,
|
||
destS3Config.sessionToken
|
||
);
|
||
|
||
if (result.success) {
|
||
setDestBuckets(result.buckets || []);
|
||
setShowDestBuckets(true);
|
||
addNotification(`✅ Found ${result.count} destination bucket(s)`, 'success');
|
||
} else {
|
||
addNotification(`❌ Failed to list destination buckets: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error listing destination buckets: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const selectSourceBucket = (bucketName) => {
|
||
setSelectedSourceBucket(bucketName);
|
||
setSourceS3Config(prev => ({ ...prev, bucket: bucketName }));
|
||
setShowSourceBuckets(false);
|
||
addNotification(`✅ Selected source bucket: ${bucketName}`, 'success');
|
||
};
|
||
|
||
const selectDestBucket = (bucketName) => {
|
||
setSelectedDestBucket(bucketName);
|
||
setDestS3Config(prev => ({ ...prev, bucket: bucketName }));
|
||
setShowDestBuckets(false);
|
||
addNotification(`✅ Selected destination bucket: ${bucketName}`, 'success');
|
||
};
|
||
|
||
const listSourceObjects = async () => {
|
||
if (!selectedSourceBucket) {
|
||
addNotification('Please select source bucket first', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await s3Api.listObjects(
|
||
selectedSourceBucket,
|
||
selectedPrefix,
|
||
true,
|
||
{
|
||
accessKeyId: sourceS3Config.accessKeyId,
|
||
secretAccessKey: sourceS3Config.secretAccessKey,
|
||
region: sourceS3Config.region,
|
||
endpointUrl: sourceS3Config.endpointUrl,
|
||
sessionToken: sourceS3Config.sessionToken,
|
||
maxKeys: 1000
|
||
}
|
||
);
|
||
|
||
if (result.success) {
|
||
setSourceObjects(result.objects || []);
|
||
setTotalSize(result.total_size || 0);
|
||
|
||
const groups = groupObjectsByPrefix(result.objects || [], 2);
|
||
setObjectGroups(groups);
|
||
|
||
addNotification(`✅ Found ${result.count} object(s) in ${selectedSourceBucket}`, 'success');
|
||
} else {
|
||
addNotification(`❌ Failed to list objects: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error listing objects: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const createDestinationBucket = async () => {
|
||
if (!selectedDestBucket) {
|
||
addNotification('Please enter destination bucket name', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await s3Api.createBucket(
|
||
selectedDestBucket,
|
||
destS3Config.region,
|
||
{
|
||
accessKeyId: destS3Config.accessKeyId,
|
||
secretAccessKey: destS3Config.secretAccessKey,
|
||
endpointUrl: destS3Config.endpointUrl,
|
||
sessionToken: destS3Config.sessionToken
|
||
}
|
||
);
|
||
|
||
if (result.success) {
|
||
if (result.created) {
|
||
addNotification(`✅ Bucket created successfully: ${selectedDestBucket}`, 'success');
|
||
} else {
|
||
addNotification(`ℹ️ Bucket already exists: ${selectedDestBucket}`, 'info');
|
||
}
|
||
} else {
|
||
addNotification(`❌ Failed to create bucket: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error creating bucket: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const migrateSingleObject = async (sourceKey) => {
|
||
if (!selectedSourceBucket || !selectedDestBucket) {
|
||
addNotification('Please select source and destination buckets', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await s3Api.migrateObject(
|
||
selectedSourceBucket,
|
||
sourceKey,
|
||
selectedDestBucket,
|
||
`${selectedPrefix}${sourceKey}`,
|
||
{
|
||
sourceAccessKeyId: sourceS3Config.accessKeyId,
|
||
sourceSecretAccessKey: sourceS3Config.secretAccessKey,
|
||
sourceRegion: sourceS3Config.region,
|
||
sourceEndpointUrl: sourceS3Config.endpointUrl,
|
||
sourceSessionToken: sourceS3Config.sessionToken,
|
||
|
||
destAccessKeyId: destS3Config.accessKeyId,
|
||
destSecretAccessKey: destS3Config.secretAccessKey,
|
||
destRegion: destS3Config.region,
|
||
destEndpointUrl: destS3Config.endpointUrl,
|
||
destSessionToken: destS3Config.sessionToken,
|
||
|
||
preserveMetadata: migrationOptions.preserveMetadata,
|
||
storageClass: migrationOptions.storageClass
|
||
}
|
||
);
|
||
|
||
if (result.success) {
|
||
addNotification(`✅ Migrated: ${sourceKey}`, 'success');
|
||
listSourceObjects();
|
||
} else {
|
||
addNotification(`❌ Migration failed: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error migrating object: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const migrateSelectedObjects = async () => {
|
||
if (selectedObjects.length === 0) {
|
||
addNotification('Please select objects to migrate', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await s3Api.migrateBatch(
|
||
selectedObjects.map(key => ({ key })),
|
||
selectedSourceBucket,
|
||
selectedDestBucket,
|
||
{
|
||
sourceAccessKeyId: sourceS3Config.accessKeyId,
|
||
sourceSecretAccessKey: sourceS3Config.secretAccessKey,
|
||
sourceRegion: sourceS3Config.region,
|
||
sourceEndpointUrl: sourceS3Config.endpointUrl,
|
||
sourceSessionToken: sourceS3Config.sessionToken,
|
||
|
||
destAccessKeyId: destS3Config.accessKeyId,
|
||
destSecretAccessKey: destS3Config.secretAccessKey,
|
||
destRegion: destS3Config.region,
|
||
destEndpointUrl: destS3Config.endpointUrl,
|
||
destSessionToken: destS3Config.sessionToken,
|
||
|
||
preserveMetadata: migrationOptions.preserveMetadata,
|
||
storageClass: migrationOptions.storageClass,
|
||
maxConcurrent: migrationOptions.maxConcurrent
|
||
}
|
||
);
|
||
|
||
if (result.success) {
|
||
addNotification(`✅ Migrated ${result.results?.successful?.length || 0} objects`, 'success');
|
||
setSelectedObjects([]);
|
||
listSourceObjects();
|
||
} else {
|
||
addNotification(`❌ Batch migration failed: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error in batch migration: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const parseS3Uri = async (uri) => {
|
||
try {
|
||
const result = await s3Api.parseUri(uri);
|
||
if (result.success) {
|
||
setParsedS3Uri(result);
|
||
if (result.parsed) {
|
||
setSelectedSourceBucket(result.parsed.bucket);
|
||
setSelectedPrefix(result.parsed.key || '');
|
||
}
|
||
addNotification('✅ S3 URI parsed successfully', 'success');
|
||
} else {
|
||
setParsedS3Uri(null);
|
||
addNotification(`❌ Failed to parse S3 URI: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error parsing S3 URI:', error);
|
||
addNotification(`❌ Error parsing S3 URI: ${error.message}`, 'error');
|
||
}
|
||
};
|
||
|
||
// ============================================================================
|
||
// PostgreSQL to S3 Functions
|
||
// ============================================================================
|
||
|
||
const testPgToS3PostgresConnection = async () => {
|
||
if (!pgToS3Config.postgresUri) {
|
||
addNotification('Please enter PostgreSQL URI', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const result = await postgresToS3Api.testPostgresConnection(pgToS3Config.postgresUri);
|
||
|
||
if (result.success) {
|
||
addNotification(`✅ PostgreSQL connection successful!`, 'success');
|
||
} else {
|
||
addNotification(`❌ PostgreSQL connection failed: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error testing PostgreSQL connection: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const testPgToS3S3Connection = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await postgresToS3Api.testS3Connection(
|
||
sourceS3Config.accessKeyId,
|
||
sourceS3Config.secretAccessKey,
|
||
sourceS3Config.region,
|
||
sourceS3Config.endpointUrl
|
||
);
|
||
|
||
if (result.success) {
|
||
addNotification(`✅ S3 connection successful!`, 'success');
|
||
} else {
|
||
addNotification(`❌ S3 connection failed: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error testing S3 connection: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
// ============================================================================
|
||
// Progress Tracking Functions
|
||
// ============================================================================
|
||
|
||
const startProgressTracking = (migrationId, type = 's3') => {
|
||
setShowProgressModal(true);
|
||
setProgressData({
|
||
migrationId,
|
||
type,
|
||
status: 'connecting',
|
||
percentage: 0,
|
||
startTime: new Date().toISOString(),
|
||
logs: [{
|
||
timestamp: new Date().toISOString(),
|
||
message: `🔄 Connecting to migration stream for ${migrationId}...`,
|
||
level: 'info'
|
||
}]
|
||
});
|
||
|
||
const stream = createProgressStream(migrationId, type, {
|
||
onProgress: (progress) => {
|
||
setProgressData(prev => {
|
||
const percentage = progress.percentage || progress.percentages?.size || 0;
|
||
|
||
const newLogs = [...(prev?.logs || [])];
|
||
if (prev?.percentage < 25 && percentage >= 25) {
|
||
newLogs.push({
|
||
timestamp: new Date().toISOString(),
|
||
message: `✅ 25% complete - ${progress.processed_size_formatted || progress.processed?.size_formatted} transferred`,
|
||
level: 'success'
|
||
});
|
||
} else if (prev?.percentage < 50 && percentage >= 50) {
|
||
newLogs.push({
|
||
timestamp: new Date().toISOString(),
|
||
message: `✅ 50% complete - halfway there!`,
|
||
level: 'success'
|
||
});
|
||
} else if (prev?.percentage < 75 && percentage >= 75) {
|
||
newLogs.push({
|
||
timestamp: new Date().toISOString(),
|
||
message: `✅ 75% complete - almost done!`,
|
||
level: 'success'
|
||
});
|
||
}
|
||
|
||
return {
|
||
...prev,
|
||
...progress,
|
||
percentage,
|
||
status: 'running',
|
||
logs: newLogs
|
||
};
|
||
});
|
||
|
||
updateProgressInUI(migrationId, progress);
|
||
},
|
||
onComplete: (completion) => {
|
||
setProgressData(prev => ({
|
||
...prev,
|
||
status: 'completed',
|
||
percentage: 100,
|
||
completedAt: new Date().toISOString(),
|
||
logs: [...(prev?.logs || []), {
|
||
timestamp: new Date().toISOString(),
|
||
message: '✅ Migration completed successfully!',
|
||
level: 'success'
|
||
}]
|
||
}));
|
||
|
||
addNotification(`✅ Migration ${migrationId} completed!`, 'success');
|
||
|
||
setProgressHistory(prev => [...prev, {
|
||
migrationId,
|
||
type,
|
||
completedAt: new Date().toISOString(),
|
||
status: 'completed',
|
||
totalTime: progressData?.elapsed_time || 0
|
||
}]);
|
||
|
||
loadMigrations(type);
|
||
|
||
setTimeout(() => {
|
||
if (progressStream === stream) {
|
||
setShowProgressModal(false);
|
||
}
|
||
}, 3000);
|
||
},
|
||
onError: (error) => {
|
||
setProgressData(prev => ({
|
||
...prev,
|
||
status: 'error',
|
||
error: error.error,
|
||
logs: [...(prev?.logs || []), {
|
||
timestamp: new Date().toISOString(),
|
||
message: `❌ Error: ${error.error}`,
|
||
level: 'error'
|
||
}]
|
||
}));
|
||
|
||
addNotification(`❌ Migration error: ${error.error}`, 'error');
|
||
|
||
setProgressHistory(prev => [...prev, {
|
||
migrationId,
|
||
type,
|
||
completedAt: new Date().toISOString(),
|
||
status: 'failed',
|
||
error: error.error
|
||
}]);
|
||
},
|
||
reconnectInterval: 3000,
|
||
maxReconnectAttempts: 5
|
||
});
|
||
|
||
setProgressStream(stream);
|
||
return stream;
|
||
};
|
||
|
||
const stopProgressTracking = () => {
|
||
if (progressStream) {
|
||
progressStream.stop();
|
||
setProgressStream(null);
|
||
}
|
||
setShowProgressModal(false);
|
||
setProgressData(null);
|
||
};
|
||
|
||
const updateProgressInUI = (migrationId, progress) => {
|
||
const percentage = progress.percentage || progress.percentages?.size || 0;
|
||
|
||
setTimeout(() => {
|
||
const migrationCards = document.querySelectorAll('.migration-card');
|
||
migrationCards.forEach(card => {
|
||
const idElement = card.querySelector('.migration-id');
|
||
if (idElement && idElement.textContent === migrationId) {
|
||
let progressBar = card.querySelector('.progress-bar-fill');
|
||
let progressText = card.querySelector('.progress-text');
|
||
|
||
if (!progressBar) {
|
||
const progressContainer = document.createElement('div');
|
||
progressContainer.className = 'progress-bar-container';
|
||
progressContainer.innerHTML = `
|
||
<div class="progress-bar-fill" style="width: ${percentage}%"></div>
|
||
<span class="progress-text">${percentage.toFixed(1)}%</span>
|
||
`;
|
||
card.querySelector('.migration-body')?.appendChild(progressContainer);
|
||
} else {
|
||
progressBar.style.width = `${percentage}%`;
|
||
if (progressText) {
|
||
progressText.textContent = `${percentage.toFixed(1)}%`;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}, 100);
|
||
};
|
||
|
||
// ============================================================================
|
||
// Migration Start Functions (using streaming)
|
||
// ============================================================================
|
||
|
||
const startPostgresMigration = async () => {
|
||
let sourceUri = postgresConfig.uri || buildPostgresConnectionString(
|
||
postgresConfig.host,
|
||
postgresConfig.database,
|
||
postgresConfig.user,
|
||
postgresConfig.port
|
||
);
|
||
|
||
let destUri = destPostgresConfig.uri || buildPostgresConnectionString(
|
||
destPostgresConfig.host,
|
||
destPostgresConfig.database,
|
||
destPostgresConfig.user,
|
||
destPostgresConfig.port
|
||
);
|
||
|
||
if (!sourceUri || !destUri) {
|
||
addNotification('Please enter source and destination URIs', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
|
||
try {
|
||
const result = await postgresApi.startMigration(
|
||
sourceUri,
|
||
destUri,
|
||
selectedSchemas.length > 0 ? selectedSchemas : null,
|
||
selectedTables.length > 0 ? selectedTables : null
|
||
);
|
||
|
||
if (result.success) {
|
||
const migrationId = result.migration_id;
|
||
setActiveMigration(migrationId);
|
||
addNotification(`✅ PostgreSQL migration ${migrationId} started!`, 'success');
|
||
startProgressTracking(migrationId, 'postgres');
|
||
} else {
|
||
addNotification(`❌ Failed to start migration: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error starting migration: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const startS3Migration = async () => {
|
||
if (!selectedSourceBucket) {
|
||
addNotification('Please select source bucket', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!selectedDestBucket) {
|
||
addNotification('Please select destination bucket', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
|
||
try {
|
||
const includePatterns = migrationOptions.includePatterns
|
||
? migrationOptions.includePatterns.split(',').map(p => p.trim())
|
||
: null;
|
||
|
||
const excludePatterns = migrationOptions.excludePatterns
|
||
? migrationOptions.excludePatterns.split(',').map(p => p.trim())
|
||
: null;
|
||
|
||
const result = await s3Api.startMigration(
|
||
selectedSourceBucket,
|
||
selectedDestBucket,
|
||
selectedPrefix,
|
||
{
|
||
sourceAccessKeyId: sourceS3Config.accessKeyId,
|
||
sourceSecretAccessKey: sourceS3Config.secretAccessKey,
|
||
sourceRegion: sourceS3Config.region,
|
||
sourceEndpointUrl: sourceS3Config.endpointUrl,
|
||
sourceSessionToken: sourceS3Config.sessionToken,
|
||
|
||
destAccessKeyId: destS3Config.accessKeyId,
|
||
destSecretAccessKey: destS3Config.secretAccessKey,
|
||
destRegion: destS3Config.region,
|
||
destEndpointUrl: destS3Config.endpointUrl,
|
||
destSessionToken: destS3Config.sessionToken,
|
||
|
||
includePatterns,
|
||
excludePatterns,
|
||
preserveMetadata: migrationOptions.preserveMetadata,
|
||
storageClass: migrationOptions.storageClass,
|
||
createDestBucket: migrationOptions.createDestBucket,
|
||
maxConcurrent: migrationOptions.maxConcurrent
|
||
}
|
||
);
|
||
|
||
if (result.success) {
|
||
const migrationId = result.migration_id;
|
||
setActiveMigration(migrationId);
|
||
addNotification(`✅ S3 to S3 migration ${migrationId} started!`, 'success');
|
||
startProgressTracking(migrationId, 's3');
|
||
} else {
|
||
addNotification(`❌ Failed to start migration: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error starting migration: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const startPgToS3Migration = async () => {
|
||
if (!pgToS3Config.postgresUri) {
|
||
addNotification('Please enter PostgreSQL URI', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!pgToS3Config.s3Bucket) {
|
||
addNotification('Please enter S3 bucket name', 'warning');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
|
||
try {
|
||
const result = await postgresToS3Api.startMigration(
|
||
pgToS3Config.postgresUri,
|
||
pgToS3Config.s3Bucket,
|
||
pgToS3Config.s3Prefix,
|
||
{
|
||
schemas: selectedSchemas.length > 0 ? selectedSchemas : null,
|
||
tables: selectedTables.length > 0 ? selectedTables : null,
|
||
compress: pgToS3Config.compress,
|
||
format: pgToS3Config.format,
|
||
accessKeyId: sourceS3Config.accessKeyId,
|
||
secretAccessKey: sourceS3Config.secretAccessKey,
|
||
region: sourceS3Config.region,
|
||
endpointUrl: sourceS3Config.endpointUrl
|
||
}
|
||
);
|
||
|
||
if (result.success) {
|
||
const migrationId = result.migration_id;
|
||
setActiveMigration(migrationId);
|
||
addNotification(`✅ PostgreSQL to S3 migration ${migrationId} started!`, 'success');
|
||
startProgressTracking(migrationId, 'pg-to-s3');
|
||
} else {
|
||
addNotification(`❌ Failed to start migration: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error starting migration: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
// ============================================================================
|
||
// Common Functions
|
||
// ============================================================================
|
||
|
||
const loadMigrations = async (type = 'postgres') => {
|
||
try {
|
||
let result;
|
||
if (type === 'postgres') result = await postgresApi.listMigrations();
|
||
else if (type === 's3') result = await s3Api.listMigrations();
|
||
else if (type === 'pg-to-s3') result = await postgresToS3Api.listMigrations();
|
||
|
||
if (result?.success) {
|
||
setMigrations(result.migrations || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading migrations:', error);
|
||
}
|
||
};
|
||
|
||
const cancelMigration = async (migrationId, type = 's3') => {
|
||
try {
|
||
let result;
|
||
if (type === 's3') result = await s3Api.cancelMigration(migrationId);
|
||
|
||
if (result?.success) {
|
||
addNotification(`✅ Migration ${migrationId} cancelled`, 'success');
|
||
loadMigrations(type);
|
||
} else {
|
||
addNotification(`❌ Failed to cancel migration: ${result?.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error cancelling migration: ${error.message}`, 'error');
|
||
}
|
||
};
|
||
|
||
const injectEnvironment = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const envVars = {
|
||
PG_HOST: postgresConfig.host,
|
||
PG_PORT: postgresConfig.port.toString(),
|
||
PG_USER: postgresConfig.user,
|
||
PG_PASSWORD: postgresConfig.password,
|
||
PG_DATABASE: postgresConfig.database,
|
||
|
||
DEST_PG_HOST: destPostgresConfig.host,
|
||
DEST_PG_PORT: destPostgresConfig.port.toString(),
|
||
DEST_PG_USER: destPostgresConfig.user,
|
||
DEST_PG_PASSWORD: destPostgresConfig.password,
|
||
DEST_PG_DATABASE: destPostgresConfig.database,
|
||
|
||
SOURCE_AWS_ACCESS_KEY_ID: sourceS3Config.accessKeyId,
|
||
SOURCE_AWS_SECRET_ACCESS_KEY: sourceS3Config.secretAccessKey,
|
||
SOURCE_AWS_REGION: sourceS3Config.region,
|
||
SOURCE_AWS_ENDPOINT_URL: sourceS3Config.endpointUrl,
|
||
SOURCE_S3_BUCKET: selectedSourceBucket,
|
||
SOURCE_S3_PREFIX: selectedPrefix,
|
||
|
||
DEST_AWS_ACCESS_KEY_ID: destS3Config.accessKeyId,
|
||
DEST_AWS_SECRET_ACCESS_KEY: destS3Config.secretAccessKey,
|
||
DEST_AWS_REGION: destS3Config.region,
|
||
DEST_AWS_ENDPOINT_URL: destS3Config.endpointUrl,
|
||
DEST_S3_BUCKET: selectedDestBucket,
|
||
DEST_S3_PREFIX: selectedPrefix
|
||
};
|
||
|
||
const result = await injectEnv(envVars);
|
||
if (result.success) {
|
||
addNotification(`✅ Injected ${result.injected_variables?.length || 0} environment variables`, 'success');
|
||
loadCurrentEnv();
|
||
} else {
|
||
addNotification(`❌ Failed to inject environment: ${result.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error injecting environment: ${error.message}`, 'error');
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const clearEnvironment = async () => {
|
||
try {
|
||
const result = await injectEnv({});
|
||
if (result.success) {
|
||
addNotification('✅ Environment cleared', 'success');
|
||
loadCurrentEnv();
|
||
}
|
||
} catch (error) {
|
||
addNotification(`❌ Error clearing environment: ${error.message}`, 'error');
|
||
}
|
||
};
|
||
|
||
const copyEnvToClipboard = async () => {
|
||
if (!currentEnv || Object.keys(currentEnv).length === 0) {
|
||
addNotification('No environment variables to copy', 'warning');
|
||
return;
|
||
}
|
||
|
||
const formatted = formatEnvVars(currentEnv, envFormat);
|
||
const success = await copyToClipboard(formatted);
|
||
if (success) {
|
||
addNotification(`✅ Copied ${Object.keys(currentEnv).length} variables to clipboard as ${envFormat} format`, 'success');
|
||
} else {
|
||
addNotification(`❌ Failed to copy to clipboard`, 'error');
|
||
}
|
||
};
|
||
|
||
const toggleObjectSelection = (key) => {
|
||
setSelectedObjects(prev =>
|
||
prev.includes(key)
|
||
? prev.filter(k => k !== key)
|
||
: [...prev, key]
|
||
);
|
||
};
|
||
|
||
const toggleTableSelection = (tableName) => {
|
||
setSelectedTables(prev =>
|
||
prev.includes(tableName)
|
||
? prev.filter(t => t !== tableName)
|
||
: [...prev, tableName]
|
||
);
|
||
};
|
||
|
||
const toggleSchemaSelection = (schemaName) => {
|
||
setSelectedSchemas(prev =>
|
||
prev.includes(schemaName)
|
||
? prev.filter(s => s !== schemaName)
|
||
: [...prev, schemaName]
|
||
);
|
||
};
|
||
|
||
const selectAllObjects = () => {
|
||
setSelectedObjects(sourceObjects.map(obj => obj.key));
|
||
};
|
||
|
||
const selectAllTables = () => {
|
||
setSelectedTables(postgresTables.map(table => table.name));
|
||
};
|
||
|
||
const clearSelectedObjects = () => {
|
||
setSelectedObjects([]);
|
||
};
|
||
|
||
const clearSelectedTables = () => {
|
||
setSelectedTables([]);
|
||
};
|
||
|
||
// ============================================================================
|
||
// Render Functions
|
||
// ============================================================================
|
||
|
||
const renderProgressModal = () => {
|
||
if (!showProgressModal || !progressData) return null;
|
||
|
||
const progress = progressData;
|
||
const percentage = progress.percentage || 0;
|
||
const bar = createProgressBar(percentage);
|
||
|
||
return (
|
||
<div className="progress-modal">
|
||
<div className="modal-content">
|
||
<div className="modal-header">
|
||
<h3>
|
||
<span className="migration-status-badge" style={{
|
||
backgroundColor: progress.status === 'running' ? '#4CAF50' :
|
||
progress.status === 'completed' ? '#2196F3' :
|
||
progress.status === 'error' ? '#f44336' : '#FF9800'
|
||
}}></span>
|
||
Migration Progress: {progress.migrationId}
|
||
</h3>
|
||
<button className="close-modal" onClick={stopProgressTracking}>×</button>
|
||
</div>
|
||
|
||
<div className="modal-body">
|
||
<div className="progress-container">
|
||
<div className="progress-bar-container">
|
||
<div
|
||
className="progress-bar-fill"
|
||
style={{ width: `${percentage}%` }}
|
||
/>
|
||
<span className="progress-bar-label">{percentage.toFixed(1)}%</span>
|
||
</div>
|
||
|
||
<pre className="progress-bar-text">{bar}</pre>
|
||
|
||
<div className="progress-stats-grid">
|
||
{progress.processed_size_formatted && progress.total_size_formatted && (
|
||
<div className="stat-item">
|
||
<span className="stat-label">Size</span>
|
||
<span className="stat-value">{progress.processed_size_formatted} / {progress.total_size_formatted}</span>
|
||
</div>
|
||
)}
|
||
|
||
{progress.processed_objects !== undefined && progress.total_objects !== undefined && (
|
||
<div className="stat-item">
|
||
<span className="stat-label">Objects</span>
|
||
<span className="stat-value">{progress.processed_objects} / {progress.total_objects}</span>
|
||
</div>
|
||
)}
|
||
|
||
{progress.processed?.tables !== undefined && progress.total?.tables !== undefined && (
|
||
<div className="stat-item">
|
||
<span className="stat-label">Tables</span>
|
||
<span className="stat-value">{progress.processed.tables} / {progress.total.tables}</span>
|
||
</div>
|
||
)}
|
||
|
||
{progress.current_speed_formatted && (
|
||
<div className="stat-item">
|
||
<span className="stat-label">Speed</span>
|
||
<span className="stat-value">{progress.current_speed_formatted}</span>
|
||
</div>
|
||
)}
|
||
|
||
{progress.eta_formatted && (
|
||
<div className="stat-item">
|
||
<span className="stat-label">ETA</span>
|
||
<span className="stat-value">{progress.eta_formatted}</span>
|
||
</div>
|
||
)}
|
||
|
||
{progress.elapsed_time_formatted && (
|
||
<div className="stat-item">
|
||
<span className="stat-label">Elapsed</span>
|
||
<span className="stat-value">{progress.elapsed_time_formatted}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{(progress.current_object || progress.current_table) && (
|
||
<div className="current-item">
|
||
<h4>Currently Processing:</h4>
|
||
{progress.current_object && (
|
||
<div className="current-object">
|
||
<span className="current-icon">📄</span>
|
||
<span className="current-name">{progress.current_object}</span>
|
||
</div>
|
||
)}
|
||
{progress.current_table && (
|
||
<div className="current-table">
|
||
<span className="current-icon">📊</span>
|
||
<span className="current-name">{progress.current_table.name}</span>
|
||
{progress.current_table.rows && (
|
||
<span className="current-progress">
|
||
{progress.current_table.rows_processed} / {progress.current_table.rows} rows
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{progress.logs && progress.logs.length > 0 && (
|
||
<div className="progress-logs">
|
||
<h4>Activity Log</h4>
|
||
<div className="logs-container">
|
||
{progress.logs.slice(-10).map((log, index) => (
|
||
<div key={index} className={`log-entry ${log.level || 'info'}`}>
|
||
<span className="log-time">
|
||
{new Date(log.timestamp).toLocaleTimeString()}
|
||
</span>
|
||
<span className="log-message">{log.message}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="progress-controls">
|
||
{progress.status === 'running' && (
|
||
<button onClick={stopProgressTracking} className="btn btn-warning">
|
||
Stop Tracking
|
||
</button>
|
||
)}
|
||
{progress.status === 'completed' && (
|
||
<button onClick={() => setShowProgressModal(false)} className="btn btn-primary">
|
||
Close
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderProgressHistory = () => {
|
||
if (progressHistory.length === 0) return null;
|
||
|
||
return (
|
||
<div className="progress-history">
|
||
<h4>Recent Migrations</h4>
|
||
<div className="history-list">
|
||
{progressHistory.slice(-5).reverse().map((item, index) => (
|
||
<div key={index} className={`history-item ${item.status}`}>
|
||
<span className="history-id">{item.migrationId}</span>
|
||
<span className="history-type">{item.type}</span>
|
||
<span className="history-status">{item.status}</span>
|
||
<span className="history-time">
|
||
{new Date(item.completedAt).toLocaleTimeString()}
|
||
</span>
|
||
{item.totalTime && (
|
||
<span className="history-time">{formatTimeEstimate(item.totalTime)}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ============================================================================
|
||
// Render
|
||
// ============================================================================
|
||
return (
|
||
<div className="App">
|
||
{/* Notifications */}
|
||
<div className="notifications">
|
||
{notifications.map(notification => (
|
||
<div key={notification.id} className={`notification ${notification.type}`}>
|
||
{notification.message}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<header className="App-header">
|
||
<h1>🔄 Unified Migration Tool</h1>
|
||
<p>PostgreSQL, S3 to S3, and PostgreSQL to S3 migrations</p>
|
||
</header>
|
||
|
||
<div className="container">
|
||
{/* Tabs */}
|
||
<div className="tabs">
|
||
<button className={activeTab === 'postgres' ? 'active' : ''} onClick={() => setActiveTab('postgres')}>
|
||
🐘 PostgreSQL
|
||
</button>
|
||
<button className={activeTab === 's3-source' ? 'active' : ''} onClick={() => setActiveTab('s3-source')}>
|
||
📤 Source S3
|
||
</button>
|
||
<button className={activeTab === 's3-dest' ? 'active' : ''} onClick={() => setActiveTab('s3-dest')}>
|
||
📥 Destination S3
|
||
</button>
|
||
<button className={activeTab === 's3-browse' ? 'active' : ''} onClick={() => setActiveTab('s3-browse')}>
|
||
📁 Browse S3
|
||
</button>
|
||
<button className={activeTab === 'pg-to-s3' ? 'active' : ''} onClick={() => setActiveTab('pg-to-s3')}>
|
||
🔄 PostgreSQL → S3
|
||
</button>
|
||
<button className={activeTab === 'migration' ? 'active' : ''} onClick={() => setActiveTab('migration')}>
|
||
🚀 Migrations
|
||
</button>
|
||
<button className={activeTab === 'environment' ? 'active' : ''} onClick={() => setActiveTab('environment')}>
|
||
⚙️ Environment
|
||
</button>
|
||
</div>
|
||
|
||
{/* ==================== PostgreSQL Tab ==================== */}
|
||
{activeTab === 'postgres' && (
|
||
<div className="tab-content">
|
||
<h2>🐘 PostgreSQL Configuration</h2>
|
||
|
||
<div className="migration-layout">
|
||
<div className="migration-column">
|
||
<div className="config-section">
|
||
<h3>Source PostgreSQL</h3>
|
||
<div className="config-grid">
|
||
<div className="config-group">
|
||
<label>Host:</label>
|
||
<input
|
||
type="text"
|
||
value={postgresConfig.host}
|
||
onChange={(e) => setPostgresConfig(prev => ({ ...prev, host: e.target.value, uri: '' }))}
|
||
placeholder="localhost"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Port:</label>
|
||
<input
|
||
type="number"
|
||
value={postgresConfig.port}
|
||
onChange={(e) => setPostgresConfig(prev => ({ ...prev, port: parseInt(e.target.value) || 5432, uri: '' }))}
|
||
placeholder="5432"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>User:</label>
|
||
<input
|
||
type="text"
|
||
value={postgresConfig.user}
|
||
onChange={(e) => setPostgresConfig(prev => ({ ...prev, user: e.target.value, uri: '' }))}
|
||
placeholder="postgres"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Password:</label>
|
||
<input
|
||
type="password"
|
||
value={postgresConfig.password}
|
||
onChange={(e) => setPostgresConfig(prev => ({ ...prev, password: e.target.value, uri: '' }))}
|
||
placeholder="••••••••"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Database:</label>
|
||
<input
|
||
type="text"
|
||
value={postgresConfig.database}
|
||
onChange={(e) => setPostgresConfig(prev => ({ ...prev, database: e.target.value, uri: '' }))}
|
||
placeholder="postgres"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="uri-input-group">
|
||
<label>Or paste URI:</label>
|
||
<input
|
||
type="text"
|
||
value={postgresConfig.uri}
|
||
onChange={(e) => setPostgresConfig(prev => ({ ...prev, uri: e.target.value }))}
|
||
placeholder="postgresql://user:pass@host:5432/database"
|
||
className="uri-input"
|
||
/>
|
||
<button onClick={() => parsePostgresUri(postgresConfig.uri, true)} className="btn-small">
|
||
Parse
|
||
</button>
|
||
</div>
|
||
|
||
<div className="action-group">
|
||
<button onClick={() => testPostgresConnection(true)} disabled={loading} className="btn btn-test">
|
||
Test Source
|
||
</button>
|
||
<button onClick={() => getPostgresSchemas(true)} disabled={loading} className="btn btn-primary">
|
||
📚 Get Schemas
|
||
</button>
|
||
<button onClick={() => getPostgresTables('', true)} disabled={loading} className="btn btn-primary">
|
||
📊 Get Tables
|
||
</button>
|
||
</div>
|
||
|
||
{postgresConnectionStatus && (
|
||
<div className={`status-message ${postgresConnectionStatus.success ? 'success' : 'error'}`}>
|
||
<h4>Source Connection Status:</h4>
|
||
<p><strong>Success:</strong> {postgresConnectionStatus.success ? '✅ Yes' : '❌ No'}</p>
|
||
{postgresConnectionStatus.host && <p><strong>Host:</strong> {postgresConnectionStatus.host}:{postgresConnectionStatus.port}</p>}
|
||
{postgresConnectionStatus.version && <p><strong>Version:</strong> {postgresConnectionStatus.version}</p>}
|
||
{postgresConnectionStatus.database && <p><strong>Database:</strong> {postgresConnectionStatus.database}</p>}
|
||
{postgresConnectionStatus.error && <p><strong>Error:</strong> {postgresConnectionStatus.error}</p>}
|
||
{postgresConnectionStatus.diagnostic && (
|
||
<div className="diagnostic">
|
||
<p><strong>Diagnostic:</strong> {postgresConnectionStatus.diagnostic.message}</p>
|
||
<p><strong>Reason:</strong> {postgresConnectionStatus.diagnostic.reason}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="migration-column">
|
||
<div className="config-section">
|
||
<h3>Destination PostgreSQL</h3>
|
||
<div className="config-grid">
|
||
<div className="config-group">
|
||
<label>Host:</label>
|
||
<input
|
||
type="text"
|
||
value={destPostgresConfig.host}
|
||
onChange={(e) => setDestPostgresConfig(prev => ({ ...prev, host: e.target.value, uri: '' }))}
|
||
placeholder="localhost"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Port:</label>
|
||
<input
|
||
type="number"
|
||
value={destPostgresConfig.port}
|
||
onChange={(e) => setDestPostgresConfig(prev => ({ ...prev, port: parseInt(e.target.value) || 5432, uri: '' }))}
|
||
placeholder="5432"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>User:</label>
|
||
<input
|
||
type="text"
|
||
value={destPostgresConfig.user}
|
||
onChange={(e) => setDestPostgresConfig(prev => ({ ...prev, user: e.target.value, uri: '' }))}
|
||
placeholder="postgres"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Password:</label>
|
||
<input
|
||
type="password"
|
||
value={destPostgresConfig.password}
|
||
onChange={(e) => setDestPostgresConfig(prev => ({ ...prev, password: e.target.value, uri: '' }))}
|
||
placeholder="••••••••"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Database:</label>
|
||
<input
|
||
type="text"
|
||
value={destPostgresConfig.database}
|
||
onChange={(e) => setDestPostgresConfig(prev => ({ ...prev, database: e.target.value, uri: '' }))}
|
||
placeholder="postgres"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="uri-input-group">
|
||
<label>Or paste URI:</label>
|
||
<input
|
||
type="text"
|
||
value={destPostgresConfig.uri}
|
||
onChange={(e) => setDestPostgresConfig(prev => ({ ...prev, uri: e.target.value }))}
|
||
placeholder="postgresql://user:pass@host:5432/database"
|
||
className="uri-input"
|
||
/>
|
||
<button onClick={() => parsePostgresUri(destPostgresConfig.uri, false)} className="btn-small">
|
||
Parse
|
||
</button>
|
||
</div>
|
||
|
||
<div className="action-group">
|
||
<button onClick={() => testPostgresConnection(false)} disabled={loading} className="btn btn-test">
|
||
Test Destination
|
||
</button>
|
||
</div>
|
||
|
||
{destPostgresConnectionStatus && (
|
||
<div className={`status-message ${destPostgresConnectionStatus.success ? 'success' : 'error'}`}>
|
||
<h4>Destination Connection Status:</h4>
|
||
<p><strong>Success:</strong> {destPostgresConnectionStatus.success ? '✅ Yes' : '❌ No'}</p>
|
||
{destPostgresConnectionStatus.host && <p><strong>Host:</strong> {destPostgresConnectionStatus.host}:{destPostgresConnectionStatus.port}</p>}
|
||
{destPostgresConnectionStatus.version && <p><strong>Version:</strong> {destPostgresConnectionStatus.version}</p>}
|
||
{destPostgresConnectionStatus.database && <p><strong>Database:</strong> {destPostgresConnectionStatus.database}</p>}
|
||
{destPostgresConnectionStatus.error && <p><strong>Error:</strong> {destPostgresConnectionStatus.error}</p>}
|
||
{destPostgresConnectionStatus.diagnostic && (
|
||
<div className="diagnostic">
|
||
<p><strong>Diagnostic:</strong> {destPostgresConnectionStatus.diagnostic.message}</p>
|
||
<p><strong>Reason:</strong> {destPostgresConnectionStatus.diagnostic.reason}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{postgresSchemas.length > 0 && (
|
||
<div className="schemas-section">
|
||
<h3>Available Schemas</h3>
|
||
<div className="schemas-grid">
|
||
{postgresSchemas.map((schema, index) => (
|
||
<div key={index} className="schema-card">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedSchemas.includes(schema)}
|
||
onChange={() => toggleSchemaSelection(schema)}
|
||
/>
|
||
{schema}
|
||
</label>
|
||
<button onClick={() => getPostgresTables(schema, true)} className="btn-small">
|
||
View Tables
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{postgresTables.length > 0 && (
|
||
<div className="tables-section">
|
||
<h3>Tables in Source Database</h3>
|
||
<div className="tables-header">
|
||
<div className="table-actions">
|
||
<button onClick={selectAllTables} className="btn-small">
|
||
Select All
|
||
</button>
|
||
<button onClick={clearSelectedTables} className="btn-small">
|
||
Clear Selection
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{Object.entries(tableGroups).map(([schema, group]) => (
|
||
<div key={schema} className="table-group">
|
||
<h4>📚 Schema: {schema}</h4>
|
||
<div className="group-stats">
|
||
<span>{group.count} tables</span>
|
||
</div>
|
||
<table className="tables-table">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: '30px' }}>
|
||
<input
|
||
type="checkbox"
|
||
onChange={(e) => {
|
||
const groupTables = group.tables.map(t => t.name);
|
||
if (e.target.checked) {
|
||
setSelectedTables([...new Set([...selectedTables, ...groupTables])]);
|
||
} else {
|
||
setSelectedTables(selectedTables.filter(name => !groupTables.includes(name)));
|
||
}
|
||
}}
|
||
checked={group.tables.every(t => selectedTables.includes(t.name))}
|
||
/>
|
||
</th>
|
||
<th>Table Name</th>
|
||
<th>Type</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{group.tables.map((table, index) => (
|
||
<tr key={index} className={selectedTables.includes(table.name) ? 'selected' : ''}>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedTables.includes(table.name)}
|
||
onChange={() => toggleTableSelection(table.name)}
|
||
/>
|
||
</td>
|
||
<td className="table-name">{table.name}</td>
|
||
<td className="table-type">{table.type}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ==================== Source S3 Tab ==================== */}
|
||
{activeTab === 's3-source' && (
|
||
<div className="tab-content">
|
||
<h2>📤 Source S3 Configuration</h2>
|
||
|
||
<div className="connection-section">
|
||
<h3>AWS Credentials</h3>
|
||
<div className="config-grid">
|
||
<div className="config-group">
|
||
<label>Access Key ID:</label>
|
||
<input
|
||
type="password"
|
||
value={sourceS3Config.accessKeyId}
|
||
onChange={(e) => setSourceS3Config(prev => ({ ...prev, accessKeyId: e.target.value }))}
|
||
placeholder="AKIAIOSFODNN7EXAMPLE"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Secret Access Key:</label>
|
||
<input
|
||
type="password"
|
||
value={sourceS3Config.secretAccessKey}
|
||
onChange={(e) => setSourceS3Config(prev => ({ ...prev, secretAccessKey: e.target.value }))}
|
||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Region:</label>
|
||
<input
|
||
type="text"
|
||
value={sourceS3Config.region}
|
||
onChange={(e) => setSourceS3Config(prev => ({ ...prev, region: e.target.value }))}
|
||
placeholder="us-east-1"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Endpoint URL:</label>
|
||
<input
|
||
type="text"
|
||
value={sourceS3Config.endpointUrl}
|
||
onChange={(e) => setSourceS3Config(prev => ({ ...prev, endpointUrl: e.target.value }))}
|
||
placeholder="https://s3.amazonaws.com (optional)"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Session Token:</label>
|
||
<input
|
||
type="password"
|
||
value={sourceS3Config.sessionToken}
|
||
onChange={(e) => setSourceS3Config(prev => ({ ...prev, sessionToken: e.target.value }))}
|
||
placeholder="Optional for temporary credentials"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="s3-actions">
|
||
<button onClick={testSourceS3Connection} disabled={loading} className="btn btn-test">
|
||
Test Source Connection
|
||
</button>
|
||
<button onClick={listSourceBuckets} disabled={loading} className="btn btn-primary">
|
||
📁 List Buckets
|
||
</button>
|
||
</div>
|
||
|
||
{sourceS3ConnectionStatus && (
|
||
<div className={`status-message ${sourceS3ConnectionStatus.success ? 'success' : 'error'}`}>
|
||
<h4>Source Connection Status:</h4>
|
||
<p><strong>Success:</strong> {sourceS3ConnectionStatus.success ? '✅ Yes' : '❌ No'}</p>
|
||
{sourceS3ConnectionStatus.endpoint && <p><strong>Endpoint:</strong> {sourceS3ConnectionStatus.endpoint}</p>}
|
||
{sourceS3ConnectionStatus.region && <p><strong>Region:</strong> {sourceS3ConnectionStatus.region}</p>}
|
||
{sourceS3ConnectionStatus.bucketCount !== undefined && <p><strong>Buckets Found:</strong> {sourceS3ConnectionStatus.bucketCount}</p>}
|
||
{sourceS3ConnectionStatus.error && <p><strong>Error:</strong> {sourceS3ConnectionStatus.error}</p>}
|
||
{sourceS3ConnectionStatus.diagnostic && (
|
||
<div className="diagnostic">
|
||
<p><strong>Diagnostic:</strong> {sourceS3ConnectionStatus.diagnostic.message}</p>
|
||
<p><strong>Reason:</strong> {sourceS3ConnectionStatus.diagnostic.reason}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="bucket-section">
|
||
<h3>Source Bucket Selection</h3>
|
||
|
||
<div className="s3-input-group">
|
||
<input
|
||
type="text"
|
||
placeholder="Source bucket name"
|
||
value={selectedSourceBucket}
|
||
onChange={(e) => setSelectedSourceBucket(e.target.value)}
|
||
className="bucket-input"
|
||
/>
|
||
</div>
|
||
|
||
{showSourceBuckets && sourceBuckets.length > 0 && (
|
||
<div className="buckets-list">
|
||
<h4>Available Source Buckets:</h4>
|
||
<div className="buckets-grid">
|
||
{sourceBuckets.map((bucket, index) => (
|
||
<div key={index} className="bucket-card" onClick={() => selectSourceBucket(bucket.name)}>
|
||
<div className="bucket-name">{bucket.name}</div>
|
||
<div className="bucket-details">
|
||
<span className="bucket-region">Region: {bucket.region}</span>
|
||
<span className="bucket-created">Created: {new Date(bucket.creation_date).toLocaleDateString()}</span>
|
||
<span className="bucket-size">Objects: {bucket.object_count}</span>
|
||
<span className="bucket-size">Size: {formatFileSize(bucket.total_size)}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button onClick={() => setShowSourceBuckets(false)} className="btn-small">
|
||
Hide Buckets
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ==================== Destination S3 Tab ==================== */}
|
||
{activeTab === 's3-dest' && (
|
||
<div className="tab-content">
|
||
<h2>📥 Destination S3 Configuration</h2>
|
||
|
||
<div className="connection-section">
|
||
<h3>AWS Credentials</h3>
|
||
<div className="config-grid">
|
||
<div className="config-group">
|
||
<label>Access Key ID:</label>
|
||
<input
|
||
type="password"
|
||
value={destS3Config.accessKeyId}
|
||
onChange={(e) => setDestS3Config(prev => ({ ...prev, accessKeyId: e.target.value }))}
|
||
placeholder="AKIAIOSFODNN7EXAMPLE"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Secret Access Key:</label>
|
||
<input
|
||
type="password"
|
||
value={destS3Config.secretAccessKey}
|
||
onChange={(e) => setDestS3Config(prev => ({ ...prev, secretAccessKey: e.target.value }))}
|
||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Region:</label>
|
||
<input
|
||
type="text"
|
||
value={destS3Config.region}
|
||
onChange={(e) => setDestS3Config(prev => ({ ...prev, region: e.target.value }))}
|
||
placeholder="us-east-1"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Endpoint URL:</label>
|
||
<input
|
||
type="text"
|
||
value={destS3Config.endpointUrl}
|
||
onChange={(e) => setDestS3Config(prev => ({ ...prev, endpointUrl: e.target.value }))}
|
||
placeholder="https://s3.amazonaws.com (optional)"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Session Token:</label>
|
||
<input
|
||
type="password"
|
||
value={destS3Config.sessionToken}
|
||
onChange={(e) => setDestS3Config(prev => ({ ...prev, sessionToken: e.target.value }))}
|
||
placeholder="Optional for temporary credentials"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="s3-actions">
|
||
<button onClick={testDestinationS3Connection} disabled={loading} className="btn btn-test">
|
||
Test Destination Connection
|
||
</button>
|
||
<button onClick={listDestinationBuckets} disabled={loading} className="btn btn-primary">
|
||
📁 List Buckets
|
||
</button>
|
||
</div>
|
||
|
||
{destS3ConnectionStatus && (
|
||
<div className={`status-message ${destS3ConnectionStatus.success ? 'success' : 'error'}`}>
|
||
<h4>Destination Connection Status:</h4>
|
||
<p><strong>Success:</strong> {destS3ConnectionStatus.success ? '✅ Yes' : '❌ No'}</p>
|
||
{destS3ConnectionStatus.endpoint && <p><strong>Endpoint:</strong> {destS3ConnectionStatus.endpoint}</p>}
|
||
{destS3ConnectionStatus.region && <p><strong>Region:</strong> {destS3ConnectionStatus.region}</p>}
|
||
{destS3ConnectionStatus.bucketCount !== undefined && <p><strong>Buckets Found:</strong> {destS3ConnectionStatus.bucketCount}</p>}
|
||
{destS3ConnectionStatus.error && <p><strong>Error:</strong> {destS3ConnectionStatus.error}</p>}
|
||
{destS3ConnectionStatus.diagnostic && (
|
||
<div className="diagnostic">
|
||
<p><strong>Diagnostic:</strong> {destS3ConnectionStatus.diagnostic.message}</p>
|
||
<p><strong>Reason:</strong> {destS3ConnectionStatus.diagnostic.reason}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="bucket-section">
|
||
<h3>Destination Bucket</h3>
|
||
|
||
<div className="s3-input-group">
|
||
<input
|
||
type="text"
|
||
placeholder="Destination bucket name"
|
||
value={selectedDestBucket}
|
||
onChange={(e) => setSelectedDestBucket(e.target.value)}
|
||
className="bucket-input"
|
||
/>
|
||
<button onClick={createDestinationBucket} disabled={loading} className="btn btn-success">
|
||
➕ Create Bucket
|
||
</button>
|
||
</div>
|
||
|
||
{showDestBuckets && destBuckets.length > 0 && (
|
||
<div className="buckets-list">
|
||
<h4>Available Destination Buckets:</h4>
|
||
<div className="buckets-grid">
|
||
{destBuckets.map((bucket, index) => (
|
||
<div key={index} className="bucket-card" onClick={() => selectDestBucket(bucket.name)}>
|
||
<div className="bucket-name">{bucket.name}</div>
|
||
<div className="bucket-details">
|
||
<span className="bucket-region">Region: {bucket.region}</span>
|
||
<span className="bucket-created">Created: {new Date(bucket.creation_date).toLocaleDateString()}</span>
|
||
<span className="bucket-size">Objects: {bucket.object_count}</span>
|
||
<span className="bucket-size">Size: {formatFileSize(bucket.total_size)}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button onClick={() => setShowDestBuckets(false)} className="btn-small">
|
||
Hide Buckets
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ==================== Browse S3 Tab ==================== */}
|
||
{activeTab === 's3-browse' && (
|
||
<div className="tab-content">
|
||
<h2>📁 Browse Source S3 Objects</h2>
|
||
|
||
<div className="browse-controls">
|
||
<div className="s3-input-group">
|
||
<label>Source Bucket:</label>
|
||
<input
|
||
type="text"
|
||
value={selectedSourceBucket}
|
||
onChange={(e) => setSelectedSourceBucket(e.target.value)}
|
||
className="bucket-input"
|
||
placeholder="source-bucket-name"
|
||
/>
|
||
|
||
<label>Prefix (optional):</label>
|
||
<input
|
||
type="text"
|
||
value={selectedPrefix}
|
||
onChange={(e) => setSelectedPrefix(e.target.value)}
|
||
className="prefix-input"
|
||
placeholder="folder/subfolder/"
|
||
/>
|
||
|
||
<button onClick={listSourceObjects} disabled={loading} className="btn btn-primary">
|
||
🔍 List Objects
|
||
</button>
|
||
</div>
|
||
|
||
<div className="s3-uri-input">
|
||
<label>Or paste S3 URI:</label>
|
||
<input
|
||
type="text"
|
||
placeholder="s3://bucket-name/path/to/objects"
|
||
onBlur={(e) => parseS3Uri(e.target.value)}
|
||
className="uri-input"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{parsedS3Uri && parsedS3Uri.success && (
|
||
<div className="parsed-info">
|
||
<h4>Parsed S3 URI:</h4>
|
||
<div className="parsed-details">
|
||
<div><strong>Bucket:</strong> {parsedS3Uri.parsed?.bucket}</div>
|
||
<div><strong>Key:</strong> {parsedS3Uri.parsed?.key || '/'}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{sourceObjects.length > 0 && (
|
||
<div className="objects-section">
|
||
<div className="objects-header">
|
||
<h3>
|
||
Objects in s3://{selectedSourceBucket}/{selectedPrefix}
|
||
<span className="badge">{sourceObjects.length} files</span>
|
||
<span className="badge">{formatFileSize(totalSize)}</span>
|
||
</h3>
|
||
|
||
<div className="object-actions">
|
||
<button onClick={selectAllObjects} className="btn-small">
|
||
Select All
|
||
</button>
|
||
<button onClick={clearSelectedObjects} className="btn-small">
|
||
Clear Selection
|
||
</button>
|
||
<button
|
||
onClick={migrateSelectedObjects}
|
||
disabled={selectedObjects.length === 0 || loading}
|
||
className="btn btn-migrate"
|
||
>
|
||
🚀 Migrate Selected ({selectedObjects.length})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Object Groups */}
|
||
{Object.entries(objectGroups).map(([prefix, group]) => (
|
||
<div key={prefix} className="object-group">
|
||
<h4>📁 {prefix}</h4>
|
||
<div className="group-stats">
|
||
<span>{group.count} objects</span>
|
||
<span>{formatFileSize(group.totalSize)}</span>
|
||
</div>
|
||
<table className="objects-table">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: '30px' }}>
|
||
<input
|
||
type="checkbox"
|
||
onChange={(e) => {
|
||
const groupKeys = group.objects.map(obj => obj.key);
|
||
if (e.target.checked) {
|
||
setSelectedObjects([...new Set([...selectedObjects, ...groupKeys])]);
|
||
} else {
|
||
setSelectedObjects(selectedObjects.filter(key => !groupKeys.includes(key)));
|
||
}
|
||
}}
|
||
checked={group.objects.every(obj => selectedObjects.includes(obj.key))}
|
||
/>
|
||
</th>
|
||
<th>Key</th>
|
||
<th>Size</th>
|
||
<th>Last Modified</th>
|
||
<th>ETag</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{group.objects.map((obj, index) => (
|
||
<tr key={index} className={selectedObjects.includes(obj.key) ? 'selected' : ''}>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedObjects.includes(obj.key)}
|
||
onChange={() => toggleObjectSelection(obj.key)}
|
||
/>
|
||
</td>
|
||
<td className="object-key">{obj.key}</td>
|
||
<td className="object-size">{formatFileSize(obj.size)}</td>
|
||
<td className="object-modified">
|
||
{new Date(obj.last_modified).toLocaleString()}
|
||
</td>
|
||
<td className="object-etag">{obj.etag?.substring(0, 8)}...</td>
|
||
<td>
|
||
<button
|
||
onClick={() => migrateSingleObject(obj.key)}
|
||
className="btn-small"
|
||
disabled={loading}
|
||
>
|
||
Migrate
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{sourceObjects.length === 0 && selectedSourceBucket && (
|
||
<div className="no-objects">
|
||
<p>📭 No objects found in s3://{selectedSourceBucket}/{selectedPrefix}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ==================== PostgreSQL to S3 Tab ==================== */}
|
||
{activeTab === 'pg-to-s3' && (
|
||
<div className="tab-content">
|
||
<h2>🔄 PostgreSQL to S3 Migration</h2>
|
||
|
||
<div className="config-section">
|
||
<h3>PostgreSQL Configuration</h3>
|
||
<div className="config-grid">
|
||
<div className="config-group full-width">
|
||
<label>PostgreSQL URI:</label>
|
||
<input
|
||
type="text"
|
||
value={pgToS3Config.postgresUri}
|
||
onChange={(e) => setPgToS3Config(prev => ({ ...prev, postgresUri: e.target.value }))}
|
||
placeholder="postgresql://user:pass@host:5432/database"
|
||
className="uri-input"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<button onClick={testPgToS3PostgresConnection} disabled={loading} className="btn btn-test">
|
||
Test PostgreSQL Connection
|
||
</button>
|
||
</div>
|
||
|
||
<div className="config-section">
|
||
<h3>S3 Configuration</h3>
|
||
<div className="config-grid">
|
||
<div className="config-group">
|
||
<label>Access Key ID:</label>
|
||
<input
|
||
type="password"
|
||
value={sourceS3Config.accessKeyId}
|
||
onChange={(e) => setSourceS3Config(prev => ({ ...prev, accessKeyId: e.target.value }))}
|
||
placeholder="AKIAIOSFODNN7EXAMPLE"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Secret Access Key:</label>
|
||
<input
|
||
type="password"
|
||
value={sourceS3Config.secretAccessKey}
|
||
onChange={(e) => setSourceS3Config(prev => ({ ...prev, secretAccessKey: e.target.value }))}
|
||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Region:</label>
|
||
<input
|
||
type="text"
|
||
value={sourceS3Config.region}
|
||
onChange={(e) => setSourceS3Config(prev => ({ ...prev, region: e.target.value }))}
|
||
placeholder="us-east-1"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Endpoint URL:</label>
|
||
<input
|
||
type="text"
|
||
value={sourceS3Config.endpointUrl}
|
||
onChange={(e) => setSourceS3Config(prev => ({ ...prev, endpointUrl: e.target.value }))}
|
||
placeholder="https://s3.amazonaws.com (optional)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<button onClick={testPgToS3S3Connection} disabled={loading} className="btn btn-test">
|
||
Test S3 Connection
|
||
</button>
|
||
</div>
|
||
|
||
<div className="config-section">
|
||
<h3>Migration Settings</h3>
|
||
<div className="config-grid">
|
||
<div className="config-group">
|
||
<label>S3 Bucket:</label>
|
||
<input
|
||
type="text"
|
||
value={pgToS3Config.s3Bucket}
|
||
onChange={(e) => setPgToS3Config(prev => ({ ...prev, s3Bucket: e.target.value }))}
|
||
placeholder="my-s3-bucket"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>S3 Prefix:</label>
|
||
<input
|
||
type="text"
|
||
value={pgToS3Config.s3Prefix}
|
||
onChange={(e) => setPgToS3Config(prev => ({ ...prev, s3Prefix: e.target.value }))}
|
||
placeholder="backups/"
|
||
/>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={pgToS3Config.compress}
|
||
onChange={(e) => setPgToS3Config(prev => ({ ...prev, compress: e.target.checked }))}
|
||
/>
|
||
Compress (GZIP)
|
||
</label>
|
||
</div>
|
||
<div className="config-group">
|
||
<label>Format:</label>
|
||
<select
|
||
value={pgToS3Config.format}
|
||
onChange={(e) => setPgToS3Config(prev => ({ ...prev, format: e.target.value }))}
|
||
>
|
||
<option value="csv">CSV</option>
|
||
<option value="json">JSON</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="migration-controls">
|
||
<button
|
||
onClick={startPgToS3Migration}
|
||
disabled={loading || !pgToS3Config.postgresUri || !pgToS3Config.s3Bucket}
|
||
className="btn btn-migrate btn-large"
|
||
>
|
||
{loading ? 'Processing...' : '🚀 Start PostgreSQL to S3 Migration'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ==================== Migration Tab ==================== */}
|
||
{activeTab === 'migration' && (
|
||
<div className="tab-content">
|
||
<h2>🚀 Migration Status</h2>
|
||
|
||
<div className="migration-options">
|
||
<h4>Migration Options</h4>
|
||
<div className="options-grid">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={migrationOptions.createDestBucket}
|
||
onChange={(e) => setMigrationOptions({...migrationOptions, createDestBucket: e.target.checked})}
|
||
/>
|
||
Create Destination Bucket if Missing
|
||
</label>
|
||
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={migrationOptions.preserveMetadata}
|
||
onChange={(e) => setMigrationOptions({...migrationOptions, preserveMetadata: e.target.checked})}
|
||
/>
|
||
Preserve Metadata
|
||
</label>
|
||
|
||
<div className="option-item">
|
||
<label>Storage Class:</label>
|
||
<select
|
||
value={migrationOptions.storageClass}
|
||
onChange={(e) => setMigrationOptions({...migrationOptions, storageClass: e.target.value})}
|
||
>
|
||
<option value="STANDARD">Standard</option>
|
||
<option value="STANDARD_IA">Standard-IA</option>
|
||
<option value="INTELLIGENT_TIERING">Intelligent-Tiering</option>
|
||
<option value="ONEZONE_IA">OneZone-IA</option>
|
||
<option value="GLACIER">Glacier</option>
|
||
<option value="DEEP_ARCHIVE">Deep Archive</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="option-item">
|
||
<label>Max Concurrent:</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="20"
|
||
value={migrationOptions.maxConcurrent}
|
||
onChange={(e) => setMigrationOptions({...migrationOptions, maxConcurrent: parseInt(e.target.value) || 5})}
|
||
/>
|
||
</div>
|
||
|
||
<div className="option-item full-width">
|
||
<label>Include Patterns (comma separated):</label>
|
||
<input
|
||
type="text"
|
||
value={migrationOptions.includePatterns}
|
||
onChange={(e) => setMigrationOptions({...migrationOptions, includePatterns: e.target.value})}
|
||
placeholder="*.csv, *.json, important/*"
|
||
/>
|
||
</div>
|
||
|
||
<div className="option-item full-width">
|
||
<label>Exclude Patterns (comma separated):</label>
|
||
<input
|
||
type="text"
|
||
value={migrationOptions.excludePatterns}
|
||
onChange={(e) => setMigrationOptions({...migrationOptions, excludePatterns: e.target.value})}
|
||
placeholder="*.tmp, *.log, temp/*"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="migration-controls">
|
||
<button
|
||
onClick={startS3Migration}
|
||
disabled={loading || !selectedSourceBucket || !selectedDestBucket}
|
||
className="btn btn-migrate btn-large"
|
||
>
|
||
{loading ? 'Processing...' : '🚀 Start S3 to S3 Migration'}
|
||
</button>
|
||
<button
|
||
onClick={startPostgresMigration}
|
||
disabled={loading || !postgresConfig.uri}
|
||
className="btn btn-migrate btn-large"
|
||
>
|
||
{loading ? 'Processing...' : '🚀 Start PostgreSQL Migration'}
|
||
</button>
|
||
</div>
|
||
|
||
{postgresTables.length > 0 && (
|
||
<div className="migration-stats">
|
||
<h4>Migration Statistics</h4>
|
||
<div className="stats-grid">
|
||
<div className="stat-card">
|
||
<div className="stat-label">Total Tables</div>
|
||
<div className="stat-value">{postgresTables.length}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Total Rows</div>
|
||
<div className="stat-value">{totalRows.toLocaleString()}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Selected Tables</div>
|
||
<div className="stat-value">{selectedTables.length}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{sourceObjects.length > 0 && (
|
||
<div className="migration-stats">
|
||
<h4>Migration Statistics</h4>
|
||
<div className="stats-grid">
|
||
<div className="stat-card">
|
||
<div className="stat-label">Total Objects</div>
|
||
<div className="stat-value">{sourceObjects.length}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Total Size</div>
|
||
<div className="stat-value">{formatFileSize(totalSize)}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Selected Objects</div>
|
||
<div className="stat-value">{selectedObjects.length}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{migrations.length > 0 && (
|
||
<div className="migrations-list">
|
||
<h3>Previous Migrations ({migrations.length})</h3>
|
||
<div className="migrations-grid">
|
||
{migrations.map((migration, index) => (
|
||
<div key={index} className="migration-card">
|
||
<div className="migration-header">
|
||
<span className="migration-id">{migration.id}</span>
|
||
<span className={`migration-status ${migration.status}`}>
|
||
{migration.status}
|
||
</span>
|
||
</div>
|
||
<div className="migration-body">
|
||
<p><strong>Status:</strong> {migration.status}</p>
|
||
<p><strong>Started:</strong> {migration.started_at ? new Date(migration.started_at * 1000).toLocaleString() : 'N/A'}</p>
|
||
</div>
|
||
<div className="migration-actions">
|
||
<button onClick={() => setActiveMigration(migration.id)} className="btn-small">
|
||
View Details
|
||
</button>
|
||
{migration.status === 'running' && (
|
||
<button onClick={() => cancelMigration(migration.id, 's3')} className="btn-small btn-warning">
|
||
Cancel
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeMigration && migrationLogs.length > 0 && (
|
||
<div className="migration-logs">
|
||
<h3>Migration Logs ({activeMigration})</h3>
|
||
<div className="logs-container">
|
||
{migrationLogs.map((log, index) => (
|
||
<div key={index} className={`log-entry ${log.level || 'info'}`}>
|
||
<span className="log-time">
|
||
{log.timestamp ? new Date(log.timestamp).toLocaleTimeString() : ''}
|
||
</span>
|
||
<span className="log-message">{log.message}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ==================== Environment Tab ==================== */}
|
||
{activeTab === 'environment' && (
|
||
<div className="tab-content">
|
||
<h2>⚙️ Environment Variables Management</h2>
|
||
|
||
<div className="env-actions">
|
||
<button onClick={loadCurrentEnv} className="btn btn-secondary">🔄 Refresh Environment</button>
|
||
<button onClick={injectEnvironment} className="btn btn-primary">⚡ Inject Environment</button>
|
||
<button onClick={clearEnvironment} className="btn btn-warning">🗑️ Clear Environment</button>
|
||
</div>
|
||
|
||
<div className="env-config-summary">
|
||
<h3>Current Configuration</h3>
|
||
<div className="config-cards">
|
||
<div className="config-card">
|
||
<h4>🐘 PostgreSQL Source</h4>
|
||
<pre>{formatPostgresConfig(postgresConfig)}</pre>
|
||
</div>
|
||
<div className="config-card">
|
||
<h4>🐘 PostgreSQL Destination</h4>
|
||
<pre>{formatPostgresConfig(destPostgresConfig)}</pre>
|
||
</div>
|
||
<div className="config-card">
|
||
<h4>📤 Source S3</h4>
|
||
<pre>{formatS3Config(sourceS3Config, 'source')}</pre>
|
||
</div>
|
||
<div className="config-card">
|
||
<h4>📥 Destination S3</h4>
|
||
<pre>{formatS3Config(destS3Config, 'destination')}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="env-format-selector">
|
||
<label>Format:</label>
|
||
<select value={envFormat} onChange={(e) => setEnvFormat(e.target.value)}>
|
||
<option value="dotenv">.env file</option>
|
||
<option value="json">JSON</option>
|
||
<option value="shell_export">Shell export</option>
|
||
<option value="docker_env">Docker -e flags</option>
|
||
<option value="docker_compose">Docker Compose</option>
|
||
</select>
|
||
<button onClick={copyEnvToClipboard} disabled={Object.keys(currentEnv).length === 0} className="btn btn-secondary">
|
||
📋 Copy to Clipboard
|
||
</button>
|
||
</div>
|
||
|
||
<div className="env-preview">
|
||
<h3>Current Environment Variables ({Object.keys(currentEnv).length})</h3>
|
||
{Object.keys(currentEnv).length > 0 ? (
|
||
<pre>{formatEnvVars(currentEnv, envFormat)}</pre>
|
||
) : (
|
||
<p className="no-env">No environment variables found. Test connections or inject environment first.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Status Bar */}
|
||
<div className="status-bar">
|
||
{loading && <div className="spinner">⏳ Processing...</div>}
|
||
<div className="status-info">
|
||
{selectedSourceBucket && <span>📤 Source: {selectedSourceBucket}</span>}
|
||
{selectedDestBucket && <span>📥 Destination: {selectedDestBucket}</span>}
|
||
{selectedPostgresDb && <span>🐘 DB: {selectedPostgresDb}</span>}
|
||
{selectedObjects.length > 0 && <span>📁 {selectedObjects.length} objects selected</span>}
|
||
{selectedTables.length > 0 && <span>📊 {selectedTables.length} tables selected</span>}
|
||
{activeMigration && <span>🚀 Active migration: {activeMigration}</span>}
|
||
<span>⚡ {Object.keys(currentEnv).length} env vars</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Progress Modal */}
|
||
{renderProgressModal()}
|
||
|
||
{/* Progress History */}
|
||
{renderProgressHistory()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App; |