رفع الملفات إلى "/"
هذا الالتزام موجود في:
206
MySQLMySQLMigrator.jsx
Normal file
206
MySQLMySQLMigrator.jsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { getSchemas, getTables, startMigration, getProgress } from "../api";
|
||||||
|
|
||||||
|
export default function MySQLMySQLMigrator() {
|
||||||
|
const [srcHost, setSrcHost] = useState("");
|
||||||
|
const [srcUser, setSrcUser] = useState("");
|
||||||
|
const [srcPass, setSrcPass] = useState("");
|
||||||
|
const [schemas, setSchemas] = useState([]);
|
||||||
|
const [selectedSchemas, setSelectedSchemas] = useState([]);
|
||||||
|
const [destHost, setDestHost] = useState("");
|
||||||
|
const [destUser, setDestUser] = useState("");
|
||||||
|
const [destPass, setDestPass] = useState("");
|
||||||
|
const [progress, setProgress] = useState({ percent: 0, message: "Waiting to start..." });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadSchemas = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getSchemas("mysql_mysql", { host: srcHost, user: srcUser, pass: srcPass });
|
||||||
|
setSchemas(data.schemas || []);
|
||||||
|
if (data.error) {
|
||||||
|
alert("Error loading databases: " + data.error);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSchemas([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMigration = async () => {
|
||||||
|
const payload = {
|
||||||
|
SRC_HOST: srcHost,
|
||||||
|
SRC_USER: srcUser,
|
||||||
|
SRC_PASS: srcPass,
|
||||||
|
DEST_HOST: destHost,
|
||||||
|
DEST_USER: destUser,
|
||||||
|
DEST_PASS: destPass,
|
||||||
|
DATABASES: selectedSchemas
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const data = await startMigration("mysql_mysql", payload);
|
||||||
|
if (data.success) {
|
||||||
|
pollProgress();
|
||||||
|
} else {
|
||||||
|
alert("Migration failed: " + (data.error || "Unknown error"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting migration:", error);
|
||||||
|
alert("Connection error: " + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollProgress = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getProgress("mysql_mysql");
|
||||||
|
setProgress(data);
|
||||||
|
if (data.status === "error") {
|
||||||
|
alert("Migration error: " + (data.message || "Unknown error"));
|
||||||
|
} else if (data.percent < 100 && data.status !== "completed") {
|
||||||
|
setTimeout(pollProgress, 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error polling progress:", error);
|
||||||
|
setTimeout(pollProgress, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
MySQL → MySQL Migration
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Migrate complete MySQL databases .
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
{/* Source Database */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
|
Source Database
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Host</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., localhost"
|
||||||
|
value={srcHost}
|
||||||
|
onChange={e => setSrcHost(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., root"
|
||||||
|
value={srcUser}
|
||||||
|
onChange={e => setSrcUser(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={srcPass}
|
||||||
|
onChange={e => setSrcPass(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadSchemas}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium py-2 px-4 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Loading..." : "Load Databases"}
|
||||||
|
</button>
|
||||||
|
{schemas.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Select Databases</label>
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
value={selectedSchemas}
|
||||||
|
onChange={e => setSelectedSchemas([...e.target.selectedOptions].map(o => o.value))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent h-32"
|
||||||
|
>
|
||||||
|
{schemas.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Database */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
|
Target Database
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Host</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., localhost"
|
||||||
|
value={destHost}
|
||||||
|
onChange={e => setDestHost(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., root"
|
||||||
|
value={destUser}
|
||||||
|
onChange={e => setDestUser(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={destPass}
|
||||||
|
onChange={e => setDestPass(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Migration Button */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={handleMigration}
|
||||||
|
disabled={selectedSchemas.length === 0}
|
||||||
|
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white font-medium py-3 px-8 rounded-md transition-colors text-lg"
|
||||||
|
>
|
||||||
|
Start Migration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
||||||
|
<h4 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">Progress</h4>
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-600 rounded-full h-4 mb-4">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-4 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress.percent}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-gray-800 text-green-400 p-4 rounded-md overflow-auto text-sm">
|
||||||
|
{JSON.stringify(progress, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
PostgreSQLPostgreSQLMigrator.jsx
Normal file
208
PostgreSQLPostgreSQLMigrator.jsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { getSchemas, getTables, startMigration, getProgress } from "../api";
|
||||||
|
|
||||||
|
export default function PostgreSQLPostgreSQLMigrator() {
|
||||||
|
const [srcHost, setSrcHost] = useState("");
|
||||||
|
const [srcUser, setSrcUser] = useState("");
|
||||||
|
const [srcPass, setSrcPass] = useState("");
|
||||||
|
const [schemas, setSchemas] = useState([]);
|
||||||
|
const [selectedSchemas, setSelectedSchemas] = useState([]);
|
||||||
|
const [destHost, setDestHost] = useState("");
|
||||||
|
const [destUser, setDestUser] = useState("");
|
||||||
|
const [destPass, setDestPass] = useState("");
|
||||||
|
const [progress, setProgress] = useState({ percent: 0, message: "Waiting to start..." });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadSchemas = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getSchemas("psql_psql", { host: srcHost, user: srcUser, pass: srcPass });
|
||||||
|
setSchemas(data.schemas || []);
|
||||||
|
if (data.error) {
|
||||||
|
alert("Error loading schemas: " + data.error);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSchemas([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMigration = async () => {
|
||||||
|
const payload = {
|
||||||
|
DB_HOST: srcHost,
|
||||||
|
DB_USER: srcUser,
|
||||||
|
DB_PASS: srcPass,
|
||||||
|
DB_NAME: selectedSchemas[0] || "",
|
||||||
|
DEST_HOST: destHost,
|
||||||
|
DEST_USER: destUser,
|
||||||
|
DEST_PASS: destPass,
|
||||||
|
DEST_NAME: selectedSchemas[0] || "",
|
||||||
|
ONLY_SCHEMAS: selectedSchemas.join(",")
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const data = await startMigration("psql_psql", payload);
|
||||||
|
if (data.success) {
|
||||||
|
pollProgress();
|
||||||
|
} else {
|
||||||
|
alert("Migration failed: " + (data.error || "Unknown error"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting migration:", error);
|
||||||
|
alert("Connection error: " + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollProgress = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getProgress("psql_psql");
|
||||||
|
setProgress(data);
|
||||||
|
if (data.status === "error") {
|
||||||
|
alert("Migration error: " + (data.message || "Unknown error"));
|
||||||
|
} else if (data.percent < 100 && data.status !== "completed") {
|
||||||
|
setTimeout(pollProgress, 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error polling progress:", error);
|
||||||
|
setTimeout(pollProgress, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
PostgreSQL → PostgreSQL Migration
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
|
Migrate PostgreSQL schemas and tables.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
{/* Source Database */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border border-gray-200 dark:border-red-600">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
Source Database
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white">Host</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., localhost"
|
||||||
|
value={srcHost}
|
||||||
|
onChange={e => setSrcHost(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-red-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 placeholder-gray-400 dark:placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., postgres"
|
||||||
|
value={srcUser}
|
||||||
|
onChange={e => setSrcUser(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-red-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 placeholder-gray-400 dark:placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={srcPass}
|
||||||
|
onChange={e => setSrcPass(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-red-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 placeholder-gray-400 dark:placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadSchemas}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-red-600 hover:bg-red-700 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? "Loading..." : "Load Schemas"}
|
||||||
|
</button>
|
||||||
|
{schemas.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white">Select Schemas</label>
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
value={selectedSchemas}
|
||||||
|
onChange={e => setSelectedSchemas([...e.target.selectedOptions].map(o => o.value))}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-red-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 h-32"
|
||||||
|
>
|
||||||
|
{schemas.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Database */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border border-gray-200 dark:border-red-600">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
Target Database
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white">Host</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., localhost"
|
||||||
|
value={destHost}
|
||||||
|
onChange={e => setDestHost(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-red-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 placeholder-gray-400 dark:placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., postgres"
|
||||||
|
value={destUser}
|
||||||
|
onChange={e => setDestUser(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-red-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 placeholder-gray-400 dark:placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={destPass}
|
||||||
|
onChange={e => setDestPass(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-red-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 placeholder-gray-400 dark:placeholder-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Migration Button */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={handleMigration}
|
||||||
|
disabled={selectedSchemas.length === 0}
|
||||||
|
className="bg-red-600 hover:bg-red-700 disabled:bg-gray-400 text-white font-medium py-3 px-8 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl disabled:cursor-not-allowed text-lg"
|
||||||
|
>
|
||||||
|
Start Migration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border border-gray-200 dark:border-red-600">
|
||||||
|
<h4 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Progress</h4>
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-600 rounded-full h-4 mb-4">
|
||||||
|
<div
|
||||||
|
className="bg-red-600 h-4 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress.percent}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-gray-800 text-red-400 p-4 rounded-lg overflow-auto text-sm border border-gray-600">
|
||||||
|
{JSON.stringify(progress, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
PostgreSQLS3Migrator.jsx
Normal file
180
PostgreSQLS3Migrator.jsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { startMigration, getProgress } from "../api";
|
||||||
|
|
||||||
|
export default function PostgreSQLS3Migrator() {
|
||||||
|
const [srcHost, setSrcHost] = useState("");
|
||||||
|
const [srcUser, setSrcUser] = useState("");
|
||||||
|
const [srcPass, setSrcPass] = useState("");
|
||||||
|
const [dbName, setDbName] = useState("");
|
||||||
|
const [destBucket, setDestBucket] = useState("");
|
||||||
|
const [awsAccess, setAwsAccess] = useState("");
|
||||||
|
const [awsSecret, setAwsSecret] = useState("");
|
||||||
|
const [progress, setProgress] = useState({ percent: 0, message: "Waiting to start..." });
|
||||||
|
|
||||||
|
const handleMigration = async () => {
|
||||||
|
const payload = {
|
||||||
|
DB_HOST: srcHost,
|
||||||
|
DB_USER: srcUser,
|
||||||
|
DB_PASS: srcPass,
|
||||||
|
DB_NAME: dbName,
|
||||||
|
DEST_BUCKET: destBucket,
|
||||||
|
DEST_ENDPOINT: "https://s3.amazonaws.com",
|
||||||
|
DEST_ACCESS: awsAccess,
|
||||||
|
DEST_SECRET: awsSecret
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const data = await startMigration("psql_s3", payload);
|
||||||
|
if (data.success) {
|
||||||
|
pollProgress();
|
||||||
|
} else {
|
||||||
|
alert("Migration failed: " + (data.error || "Unknown error"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting migration:", error);
|
||||||
|
alert("Connection error: " + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollProgress = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getProgress("psql_s3");
|
||||||
|
setProgress(data);
|
||||||
|
if (data.status === "error") {
|
||||||
|
alert("Migration error: " + (data.message || "Unknown error"));
|
||||||
|
} else if (data.percent < 100 && data.status !== "completed") {
|
||||||
|
setTimeout(pollProgress, 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error polling progress:", error);
|
||||||
|
setTimeout(pollProgress, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
PostgreSQL → S3 Migration
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Export PostgreSQL database to a compressed file in S3.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
{/* PostgreSQL Database */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
|
PostgreSQL Database
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Host</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., localhost"
|
||||||
|
value={srcHost}
|
||||||
|
onChange={e => setSrcHost(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., postgres"
|
||||||
|
value={srcUser}
|
||||||
|
onChange={e => setSrcUser(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={srcPass}
|
||||||
|
onChange={e => setSrcPass(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Database Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., mydatabase"
|
||||||
|
value={dbName}
|
||||||
|
onChange={e => setDbName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* S3 Target */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
|
S3 Target
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">S3 Bucket</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., my-bucket"
|
||||||
|
value={destBucket}
|
||||||
|
onChange={e => setDestBucket(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">AWS Access Key</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter AWS Access Key"
|
||||||
|
value={awsAccess}
|
||||||
|
onChange={e => setAwsAccess(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">AWS Secret Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter AWS Secret Key"
|
||||||
|
value={awsSecret}
|
||||||
|
onChange={e => setAwsSecret(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Migration Button */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={handleMigration}
|
||||||
|
disabled={!srcHost || !srcUser || !dbName || !destBucket || !awsAccess || !awsSecret}
|
||||||
|
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white font-medium py-3 px-8 rounded-md transition-colors text-lg"
|
||||||
|
>
|
||||||
|
Start Migration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg">
|
||||||
|
<h4 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">Progress</h4>
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-600 rounded-full h-4 mb-4">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-4 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress.percent}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-gray-800 text-green-400 p-4 rounded-md overflow-auto text-sm">
|
||||||
|
{JSON.stringify(progress, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
S3S3Migrator.jsx
Normal file
227
S3S3Migrator.jsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { listBuckets, startMigration, getProgress } from "../api";
|
||||||
|
|
||||||
|
export default function S3S3Migrator() {
|
||||||
|
const [awsAccess, setAwsAccess] = useState("");
|
||||||
|
const [awsSecret, setAwsSecret] = useState("");
|
||||||
|
const [awsRegion, setAwsRegion] = useState("us-east-1");
|
||||||
|
const [srcBucket, setSrcBucket] = useState("");
|
||||||
|
const [destBucket, setDestBucket] = useState("");
|
||||||
|
const [destEndpoint, setDestEndpoint] = useState("https://s3.amazonaws.com");
|
||||||
|
const [destAccess, setDestAccess] = useState("");
|
||||||
|
const [destSecret, setDestSecret] = useState("");
|
||||||
|
const [buckets, setBuckets] = useState([]);
|
||||||
|
const [progress, setProgress] = useState({ percent: 0, message: "Waiting to start..." });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadBuckets = async () => {
|
||||||
|
if (!awsAccess || !awsSecret) {
|
||||||
|
alert("Please enter AWS Access Key and Secret Key first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await listBuckets({ AWS_SRC_ACCESS_KEY: awsAccess, AWS_SRC_SECRET_KEY: awsSecret, AWS_SRC_REGION: awsRegion });
|
||||||
|
if (data.success) {
|
||||||
|
setBuckets(data.buckets);
|
||||||
|
console.log("Buckets loaded:", data.buckets);
|
||||||
|
} else {
|
||||||
|
alert("Failed to load buckets: " + (data.error || "Unknown error"));
|
||||||
|
setBuckets([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading buckets:", error);
|
||||||
|
alert("Connection error: " + error.message);
|
||||||
|
setBuckets([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMigration = async () => {
|
||||||
|
const payload = {
|
||||||
|
AWS_SRC_ACCESS_KEY: awsAccess,
|
||||||
|
AWS_SRC_SECRET_KEY: awsSecret,
|
||||||
|
AWS_SRC_REGION: awsRegion,
|
||||||
|
AWS_SRC_BUCKET: srcBucket,
|
||||||
|
CUMIN_DEST_ACCESS_KEY: destAccess,
|
||||||
|
CUMIN_DEST_SECRET_KEY: destSecret,
|
||||||
|
CUMIN_DEST_ENDPOINT: destEndpoint,
|
||||||
|
CUMIN_DEST_BUCKET: destBucket
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const data = await startMigration("s3_s3", payload);
|
||||||
|
if (data.success) {
|
||||||
|
pollProgress();
|
||||||
|
} else {
|
||||||
|
alert("Migration failed: " + (data.error || "Unknown error"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting migration:", error);
|
||||||
|
alert("Connection error: " + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollProgress = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getProgress("s3_s3");
|
||||||
|
setProgress(data);
|
||||||
|
if (data.status === "error") {
|
||||||
|
alert("Migration error: " + (data.message || "Unknown error"));
|
||||||
|
} else if (data.percent < 100 && data.status !== "completed") {
|
||||||
|
setTimeout(pollProgress, 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error polling progress:", error);
|
||||||
|
setTimeout(pollProgress, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
S3 → S3 Migration
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
|
Migrate files between S3 buckets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
{/* Source S3 */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border border-gray-200 dark:border-red-600">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
Source S3
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="custom-label">Access Key</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter Access Key"
|
||||||
|
value={awsAccess}
|
||||||
|
onChange={e => setAwsAccess(e.target.value)}
|
||||||
|
className="custom-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="custom-label">Secret Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter Secret Key"
|
||||||
|
value={awsSecret}
|
||||||
|
onChange={e => setAwsSecret(e.target.value)}
|
||||||
|
className="custom-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="custom-label">Region</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., us-east-1"
|
||||||
|
value={awsRegion}
|
||||||
|
onChange={e => setAwsRegion(e.target.value)}
|
||||||
|
className="custom-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadBuckets}
|
||||||
|
disabled={loading}
|
||||||
|
className="custom-button w-full"
|
||||||
|
>
|
||||||
|
{loading ? "Loading..." : "Load Buckets"}
|
||||||
|
</button>
|
||||||
|
{buckets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="custom-label">Select Source Bucket</label>
|
||||||
|
<select
|
||||||
|
value={srcBucket}
|
||||||
|
onChange={e => setSrcBucket(e.target.value)}
|
||||||
|
className="custom-select"
|
||||||
|
>
|
||||||
|
<option value="">Select a bucket</option>
|
||||||
|
{buckets.map(b => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target S3 */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border border-gray-200 dark:border-red-600">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
Target S3
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="custom-label">Destination Access Key</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter Destination Access Key"
|
||||||
|
value={destAccess}
|
||||||
|
onChange={e => setDestAccess(e.target.value)}
|
||||||
|
className="custom-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="custom-label">Destination Secret Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter Destination Secret Key"
|
||||||
|
value={destSecret}
|
||||||
|
onChange={e => setDestSecret(e.target.value)}
|
||||||
|
className="custom-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="custom-label">Destination Endpoint</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., https://s3.amazonaws.com"
|
||||||
|
value={destEndpoint}
|
||||||
|
onChange={e => setDestEndpoint(e.target.value)}
|
||||||
|
className="custom-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="custom-label">Destination Bucket</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., target-bucket"
|
||||||
|
value={destBucket}
|
||||||
|
onChange={e => setDestBucket(e.target.value)}
|
||||||
|
className="custom-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Migration Button */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={handleMigration}
|
||||||
|
disabled={!srcBucket || !destBucket || !destAccess || !destSecret}
|
||||||
|
className="custom-button"
|
||||||
|
>
|
||||||
|
Start Migration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border border-gray-200 dark:border-red-600">
|
||||||
|
<h4 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Progress</h4>
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-600 rounded-full h-4 mb-4">
|
||||||
|
<div
|
||||||
|
className="bg-red-600 h-4 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress.percent}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-gray-800 text-red-400 p-4 rounded-lg overflow-auto text-sm border border-gray-600">
|
||||||
|
{JSON.stringify(progress, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
styles.css
Normal file
137
styles.css
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
LIGHT MODE STYLES
|
||||||
|
=========================== */
|
||||||
|
:root {
|
||||||
|
--bg-color: #f4f6f8;
|
||||||
|
--container-bg: white;
|
||||||
|
--text-color: #333;
|
||||||
|
--input-border: #ccc;
|
||||||
|
--button-bg: #2563eb;
|
||||||
|
--button-color: white;
|
||||||
|
--link-color: #2563eb;
|
||||||
|
--pre-bg: #111;
|
||||||
|
--pre-color: #0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
DARK MODE STYLES
|
||||||
|
=========================== */
|
||||||
|
.dark {
|
||||||
|
--bg-color: #000000;
|
||||||
|
--container-bg: #0d0d0d;
|
||||||
|
--text-color: #ffffff;
|
||||||
|
--input-border: #ff0000;
|
||||||
|
--button-bg: #b91c1c;
|
||||||
|
--button-color: #ffffff;
|
||||||
|
--link-color: #ef4444;
|
||||||
|
--pre-bg: #000000;
|
||||||
|
--pre-color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
BODY STYLES
|
||||||
|
=========================== */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: var(--container-bg);
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
CUSTOM COMPONENTS
|
||||||
|
=========================== */
|
||||||
|
.glow-red {
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 0 20px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-input {
|
||||||
|
@apply w-full px-4 py-3 border border-gray-300 dark:border-red-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 placeholder-gray-400 dark:placeholder-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-button {
|
||||||
|
@apply bg-red-600 hover:bg-red-700 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select {
|
||||||
|
@apply w-full px-4 py-3 border border-gray-300 dark:border-red-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-label {
|
||||||
|
@apply block text-sm font-medium mb-1 text-gray-700 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: var(--container-bg);
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--container-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: border-color 0.3s, background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
select.multi-select {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--button-color);
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--pre-bg);
|
||||||
|
color: var(--pre-color);
|
||||||
|
padding: 10px;
|
||||||
|
height: 140px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
المرجع في مشكلة جديدة
حظر مستخدم