mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-05-08 14:33:23 +00:00
feat: add seft-hosted server backup
This commit is contained in:
@@ -135,6 +135,9 @@ void CoreController::initControllers()
|
||||
new SettingsController(m_serversModel, m_containersModel, m_languageModel, m_sitesModel, m_appSplitTunnelingModel, m_settings));
|
||||
m_engine->rootContext()->setContextProperty("SettingsController", m_settingsController.get());
|
||||
|
||||
m_serversBackupController.reset(new ServersBackupController(m_settings));
|
||||
m_engine->rootContext()->setContextProperty("ServersBackupController", m_serversBackupController.get());
|
||||
|
||||
m_sitesController.reset(new SitesController(m_settings, m_vpnConnection, m_sitesModel));
|
||||
m_engine->rootContext()->setContextProperty("SitesController", m_sitesController.get());
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "ui/controllers/installController.h"
|
||||
#include "ui/controllers/pageController.h"
|
||||
#include "ui/controllers/settingsController.h"
|
||||
#include "ui/controllers/serversBackupController.h"
|
||||
#include "ui/controllers/sitesController.h"
|
||||
#include "ui/controllers/systemController.h"
|
||||
|
||||
@@ -115,6 +116,7 @@ private:
|
||||
QScopedPointer<ImportController> m_importController;
|
||||
QScopedPointer<ExportController> m_exportController;
|
||||
QScopedPointer<SettingsController> m_settingsController;
|
||||
QScopedPointer<ServersBackupController> m_serversBackupController;
|
||||
QScopedPointer<SitesController> m_sitesController;
|
||||
QScopedPointer<SystemController> m_systemController;
|
||||
QScopedPointer<AppSplitTunnelingController> m_appSplitTunnelingController;
|
||||
|
||||
@@ -94,6 +94,25 @@ ErrorCode ServerController::runScript(const ServerCredentials &credentials, QStr
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode ServerController::runHostScript(const ServerCredentials &credentials, QString script,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdOut,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdErr)
|
||||
{
|
||||
QString fileName = "/tmp/amnezia_" + Utils::getRandomString(16) + ".sh";
|
||||
|
||||
ErrorCode e = uploadFileToHost(credentials, script.toUtf8(), fileName);
|
||||
if (e)
|
||||
return e;
|
||||
|
||||
QString runner = QString("sudo bash %1").arg(fileName);
|
||||
e = runScript(credentials, runner, cbReadStdOut, cbReadStdErr);
|
||||
|
||||
QString remover = QString("sudo rm -f %1").arg(fileName);
|
||||
runScript(credentials, remover, cbReadStdOut, cbReadStdErr);
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
ErrorCode ServerController::runContainerScript(const ServerCredentials &credentials, DockerContainer container, QString script,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdOut,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdErr)
|
||||
@@ -210,6 +229,37 @@ ErrorCode ServerController::uploadFileToHost(const ServerCredentials &credential
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode ServerController::downloadFileFromHost(const ServerCredentials &credentials, const QString &remotePath, const QString &localPath)
|
||||
{
|
||||
auto error = m_sshClient.connectToHost(credentials);
|
||||
if (error != ErrorCode::NoError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
error = m_sshClient.scpFileDownload(remotePath, localPath);
|
||||
|
||||
if (error != ErrorCode::NoError) {
|
||||
return error;
|
||||
}
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode ServerController::uploadFileToHostPublic(const ServerCredentials &credentials, const QString &localPath, const QString &remotePath,
|
||||
libssh::ScpOverwriteMode overwriteMode)
|
||||
{
|
||||
auto error = m_sshClient.connectToHost(credentials);
|
||||
if (error != ErrorCode::NoError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
error = m_sshClient.scpFileCopy(overwriteMode, localPath, remotePath, "backup_file");
|
||||
|
||||
if (error != ErrorCode::NoError) {
|
||||
return error;
|
||||
}
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode ServerController::rebootServer(const ServerCredentials &credentials)
|
||||
{
|
||||
QString script = QString("sudo reboot");
|
||||
|
||||
@@ -46,6 +46,10 @@ public:
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdOut = nullptr,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdErr = nullptr);
|
||||
|
||||
ErrorCode runHostScript(const ServerCredentials &credentials, QString script,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdOut = nullptr,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdErr = nullptr);
|
||||
|
||||
ErrorCode runContainerScript(const ServerCredentials &credentials, DockerContainer container, QString script,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdOut = nullptr,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdErr = nullptr);
|
||||
@@ -57,6 +61,10 @@ public:
|
||||
ErrorCode getDecryptedPrivateKey(const ServerCredentials &credentials, QString &decryptedPrivateKey,
|
||||
const std::function<QString()> &callback);
|
||||
|
||||
ErrorCode downloadFileFromHost(const ServerCredentials &credentials, const QString &remotePath, const QString &localPath);
|
||||
ErrorCode uploadFileToHostPublic(const ServerCredentials &credentials, const QString &localPath, const QString &remotePath,
|
||||
libssh::ScpOverwriteMode overwriteMode = libssh::ScpOverwriteMode::ScpOverwriteExisting);
|
||||
|
||||
private:
|
||||
ErrorCode installDockerWorker(const ServerCredentials &credentials, DockerContainer container);
|
||||
ErrorCode prepareHostWorker(const ServerCredentials &credentials, DockerContainer container, const QJsonObject &config = QJsonObject());
|
||||
|
||||
@@ -290,6 +290,86 @@ namespace libssh {
|
||||
return watcher.result();
|
||||
}
|
||||
|
||||
ErrorCode Client::scpFileDownload(const QString& remotePath, const QString& localPath)
|
||||
{
|
||||
// Use full path for SCP download
|
||||
m_scpSession = ssh_scp_new(m_session, SSH_SCP_READ, remotePath.toStdString().c_str());
|
||||
|
||||
if (m_scpSession == nullptr) {
|
||||
return fromLibsshErrorCode();
|
||||
}
|
||||
|
||||
if (ssh_scp_init(m_scpSession) != SSH_OK) {
|
||||
auto errorCode = fromLibsshErrorCode();
|
||||
closeScpSession();
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
QFutureWatcher<ErrorCode> watcher;
|
||||
connect(&watcher, &QFutureWatcher<ErrorCode>::finished, this, &Client::scpFileDownloadFinished);
|
||||
QFuture<ErrorCode> future = QtConcurrent::run([this, &remotePath, &localPath]() {
|
||||
// Pull request - this gets file info
|
||||
int result = ssh_scp_pull_request(m_scpSession);
|
||||
if (result != SSH_SCP_REQUEST_NEWFILE) {
|
||||
return fromLibsshErrorCode();
|
||||
}
|
||||
|
||||
// Accept the request
|
||||
ssh_scp_accept_request(m_scpSession);
|
||||
|
||||
// Get file size
|
||||
int fileSize = ssh_scp_request_get_size(m_scpSession);
|
||||
if (fileSize <= 0) {
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
// Open local file for writing
|
||||
QFile fout(localPath);
|
||||
if (!fout.open(QIODevice::WriteOnly)) {
|
||||
return fromFileErrorCode(fout.error());
|
||||
}
|
||||
|
||||
// Read file data in chunks
|
||||
constexpr size_t bufferSize = 16384;
|
||||
int transferred = 0;
|
||||
|
||||
while (transferred < fileSize) {
|
||||
int chunkSize = qMin(bufferSize, static_cast<size_t>(fileSize - transferred));
|
||||
QByteArray buffer(chunkSize, 0);
|
||||
|
||||
int bytesRead = ssh_scp_read(m_scpSession, buffer.data(), chunkSize);
|
||||
if (bytesRead == SSH_ERROR) {
|
||||
fout.close();
|
||||
return fromLibsshErrorCode();
|
||||
}
|
||||
|
||||
if (bytesRead != chunkSize) {
|
||||
fout.close();
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
qint64 bytesWritten = fout.write(buffer);
|
||||
if (bytesWritten != chunkSize) {
|
||||
fout.close();
|
||||
return fromFileErrorCode(fout.error());
|
||||
}
|
||||
|
||||
transferred += bytesRead;
|
||||
}
|
||||
|
||||
fout.close();
|
||||
return ErrorCode::NoError;
|
||||
});
|
||||
watcher.setFuture(future);
|
||||
|
||||
QEventLoop wait;
|
||||
QObject::connect(this, &Client::scpFileDownloadFinished, &wait, &QEventLoop::quit);
|
||||
wait.exec();
|
||||
|
||||
closeScpSession();
|
||||
return watcher.result();
|
||||
}
|
||||
|
||||
void Client::closeScpSession()
|
||||
{
|
||||
if (m_scpSession != nullptr) {
|
||||
|
||||
@@ -36,6 +36,8 @@ namespace libssh {
|
||||
const QString &localPath,
|
||||
const QString &remotePath,
|
||||
const QString &fileDesc);
|
||||
ErrorCode scpFileDownload(const QString &remotePath,
|
||||
const QString &localPath);
|
||||
ErrorCode getDecryptedPrivateKey(const ServerCredentials &credentials, QString &decryptedPrivateKey, const std::function<QString()> &passphraseCallback);
|
||||
private:
|
||||
ErrorCode closeChannel();
|
||||
@@ -52,6 +54,7 @@ namespace libssh {
|
||||
signals:
|
||||
void writeToChannelFinished();
|
||||
void scpFileCopyFinished();
|
||||
void scpFileDownloadFinished();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
826
client/ui/controllers/serversBackupController.cpp
Normal file
826
client/ui/controllers/serversBackupController.cpp
Normal file
@@ -0,0 +1,826 @@
|
||||
#include "serversBackupController.h"
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QRegularExpression>
|
||||
#include <QJsonDocument>
|
||||
#include <QStandardPaths>
|
||||
#ifdef Q_OS_ANDROID
|
||||
#include <QJniObject>
|
||||
#endif
|
||||
#ifdef Q_OS_IOS
|
||||
#include "platforms/ios/ios_controller.h"
|
||||
#endif
|
||||
#include "containers/containers_defs.h"
|
||||
|
||||
ServersBackupController::ServersBackupController(std::shared_ptr<Settings> settings, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_settings(settings)
|
||||
, m_serverController(new ServerController(settings, this))
|
||||
, m_status(Idle)
|
||||
, m_backupDir("/var/backups/amnezia")
|
||||
{
|
||||
}
|
||||
|
||||
ServersBackupController::~ServersBackupController()
|
||||
{
|
||||
}
|
||||
|
||||
void ServersBackupController::setBackupDirectory(const QString &directory)
|
||||
{
|
||||
m_backupDir = directory;
|
||||
}
|
||||
|
||||
void ServersBackupController::createBackup(const ServerCredentials &credentials)
|
||||
{
|
||||
if (m_status == InProgress) {
|
||||
emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(InProgress);
|
||||
setProgress(0, tr("Starting backup creation..."));
|
||||
|
||||
m_currentOutput.clear();
|
||||
m_currentError.clear();
|
||||
|
||||
// Получаем bash скрипт для backup
|
||||
QString script = getBackupScript();
|
||||
|
||||
// Callback для обработки stdout
|
||||
auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdOut(data, m_currentOutput);
|
||||
};
|
||||
|
||||
// Callback для обработки stderr
|
||||
auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdErr(data, m_currentError);
|
||||
};
|
||||
|
||||
// Запускаем скрипт на сервере
|
||||
ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr);
|
||||
|
||||
if (error == ErrorCode::NoError) {
|
||||
// Парсим имя созданного backup из вывода
|
||||
QRegularExpression re("backup_(\\d{8}_\\d{6})\\.tar\\.gz");
|
||||
QRegularExpressionMatch match = re.match(m_currentOutput);
|
||||
|
||||
if (match.hasMatch()) {
|
||||
QString backupFilename = "backup_" + match.captured(1) + ".tar.gz";
|
||||
setStatus(Success);
|
||||
setProgress(100, tr("Backup created successfully"));
|
||||
emit backupCreated(backupFilename);
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to parse backup filename from output: %1").arg(m_currentOutput.left(200)), ErrorCode::InternalError);
|
||||
}
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to create backup: %1").arg(m_currentError), error);
|
||||
}
|
||||
}
|
||||
|
||||
void ServersBackupController::createBackupByName(const ServerCredentials &credentials, const QString &containerName)
|
||||
{
|
||||
DockerContainer container = ContainerProps::containerFromString(containerName);
|
||||
if (container == DockerContainer::None) {
|
||||
emit errorOccurred(tr("Unknown container: %1").arg(containerName), ErrorCode::InternalError);
|
||||
return;
|
||||
}
|
||||
createContainerBackup(credentials, container);
|
||||
}
|
||||
|
||||
void ServersBackupController::createContainerBackup(const ServerCredentials &credentials, DockerContainer container)
|
||||
{
|
||||
if (m_status == InProgress) {
|
||||
emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(InProgress);
|
||||
QString containerName = ContainerProps::containerToString(container);
|
||||
setProgress(0, tr("Starting backup for container: %1...").arg(containerName));
|
||||
|
||||
m_currentOutput.clear();
|
||||
m_currentError.clear();
|
||||
|
||||
QString script = getContainerBackupScript(container);
|
||||
|
||||
auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdOut(data, m_currentOutput);
|
||||
};
|
||||
|
||||
auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdErr(data, m_currentError);
|
||||
};
|
||||
|
||||
ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr);
|
||||
|
||||
if (error == ErrorCode::NoError) {
|
||||
QRegularExpression re("backup_(\\d{8}_\\d{6})\\.tar\\.gz");
|
||||
QRegularExpressionMatch match = re.match(m_currentOutput);
|
||||
|
||||
if (match.hasMatch()) {
|
||||
QString backupFilename = "backup_" + match.captured(1) + ".tar.gz";
|
||||
setStatus(Success);
|
||||
setProgress(100, tr("Container backup created successfully"));
|
||||
emit backupCreated(backupFilename);
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to parse backup filename from output: %1").arg(m_currentOutput.left(200)), ErrorCode::InternalError);
|
||||
}
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to create container backup: %1").arg(m_currentError), error);
|
||||
}
|
||||
}
|
||||
|
||||
void ServersBackupController::createContainersBackup(const ServerCredentials &credentials, const QList<DockerContainer> &containers)
|
||||
{
|
||||
if (m_status == InProgress) {
|
||||
emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(InProgress);
|
||||
setProgress(0, tr("Starting backup for %1 containers...").arg(containers.size()));
|
||||
|
||||
m_currentOutput.clear();
|
||||
m_currentError.clear();
|
||||
|
||||
QString script = getContainersBackupScript(containers);
|
||||
|
||||
auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdOut(data, m_currentOutput);
|
||||
};
|
||||
|
||||
auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdErr(data, m_currentError);
|
||||
};
|
||||
|
||||
ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr);
|
||||
|
||||
if (error == ErrorCode::NoError) {
|
||||
QRegularExpression re("backup_(\\d{8}_\\d{6})\\.tar\\.gz");
|
||||
QRegularExpressionMatch match = re.match(m_currentOutput);
|
||||
|
||||
if (match.hasMatch()) {
|
||||
QString backupFilename = "backup_" + match.captured(1) + ".tar.gz";
|
||||
setStatus(Success);
|
||||
setProgress(100, tr("Containers backup created successfully"));
|
||||
emit backupCreated(backupFilename);
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to parse backup filename from output: %1").arg(m_currentOutput.left(200)), ErrorCode::InternalError);
|
||||
}
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to create containers backup: %1").arg(m_currentError), error);
|
||||
}
|
||||
}
|
||||
|
||||
void ServersBackupController::fetchBackupList(const ServerCredentials &credentials)
|
||||
{
|
||||
if (m_status == InProgress) {
|
||||
emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(InProgress);
|
||||
setProgress(0, tr("Fetching backup list..."));
|
||||
|
||||
m_currentOutput.clear();
|
||||
m_currentError.clear();
|
||||
|
||||
QString script = getListBackupsScript();
|
||||
|
||||
auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdOut(data, m_currentOutput);
|
||||
};
|
||||
|
||||
auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdErr(data, m_currentError);
|
||||
};
|
||||
|
||||
ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr);
|
||||
|
||||
if (error == ErrorCode::NoError) {
|
||||
QList<BackupInfo> backups = parseBackupList(m_currentOutput);
|
||||
setStatus(Success);
|
||||
setProgress(100, tr("Backup list received"));
|
||||
emit backupListReceived(backups);
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to fetch backup list: %1").arg(m_currentError), error);
|
||||
}
|
||||
}
|
||||
|
||||
void ServersBackupController::restoreBackup(const ServerCredentials &credentials,
|
||||
const QString &backupFilename,
|
||||
const QStringList &containers)
|
||||
{
|
||||
if (m_status == InProgress) {
|
||||
emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(InProgress);
|
||||
setProgress(0, tr("Starting restore from %1...").arg(backupFilename));
|
||||
|
||||
m_currentOutput.clear();
|
||||
m_currentError.clear();
|
||||
|
||||
QString script = getRestoreScript(backupFilename, containers);
|
||||
|
||||
auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdOut(data, m_currentOutput);
|
||||
};
|
||||
|
||||
auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdErr(data, m_currentError);
|
||||
};
|
||||
|
||||
ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr);
|
||||
|
||||
if (error == ErrorCode::NoError) {
|
||||
setStatus(Success);
|
||||
setProgress(100, tr("Backup restored successfully"));
|
||||
emit backupRestored();
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to restore backup: %1").arg(m_currentError), error);
|
||||
}
|
||||
}
|
||||
|
||||
void ServersBackupController::checkBackupStatus(const ServerCredentials &credentials)
|
||||
{
|
||||
if (m_status == InProgress) {
|
||||
emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(InProgress);
|
||||
setProgress(0, tr("Checking backup status..."));
|
||||
|
||||
m_currentOutput.clear();
|
||||
m_currentError.clear();
|
||||
|
||||
QString script = getCheckStatusScript();
|
||||
|
||||
auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdOut(data, m_currentOutput);
|
||||
};
|
||||
|
||||
auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdErr(data, m_currentError);
|
||||
};
|
||||
|
||||
ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr);
|
||||
|
||||
if (error == ErrorCode::NoError) {
|
||||
QJsonObject status = parseBackupStatus(m_currentOutput);
|
||||
setStatus(Success);
|
||||
setProgress(100, tr("Status received"));
|
||||
emit backupStatusReceived(status);
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to check backup status: %1").arg(m_currentError), error);
|
||||
}
|
||||
}
|
||||
|
||||
void ServersBackupController::downloadBackup(const ServerCredentials &credentials,
|
||||
const QString &backupFilename,
|
||||
const QString &localPath)
|
||||
{
|
||||
if (m_status == InProgress) {
|
||||
emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(InProgress);
|
||||
setProgress(0, tr("Downloading backup..."));
|
||||
|
||||
// Validate backup filename
|
||||
if (backupFilename.isEmpty()) {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Backup filename is empty"), ErrorCode::InternalError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct remote file path
|
||||
QString remotePath = QString("%1/%2").arg(m_backupDir, backupFilename);
|
||||
|
||||
// Determine actual local path
|
||||
QString actualLocalPath = localPath;
|
||||
QFileInfo pathInfo(localPath);
|
||||
|
||||
// If only filename provided (no directory), use appropriate folder
|
||||
if (!pathInfo.isAbsolute() || pathInfo.dir().path() == ".") {
|
||||
#ifdef Q_OS_ANDROID
|
||||
// На Android используем публичную папку Download (через JNI)
|
||||
QJniObject mediaDir = QJniObject::callStaticObjectMethod(
|
||||
"android/os/Environment",
|
||||
"getExternalStoragePublicDirectory",
|
||||
"(Ljava/lang/String;)Ljava/io/File;",
|
||||
QJniObject::getStaticObjectField("android/os/Environment", "DIRECTORY_DOWNLOADS", "Ljava/lang/String;").object());
|
||||
QString downloadsPath = mediaDir.callObjectMethod("getAbsolutePath", "()Ljava/lang/String;").toString();
|
||||
actualLocalPath = QDir(downloadsPath).filePath(backupFilename);
|
||||
#elif defined(Q_OS_IOS)
|
||||
QString tempPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
|
||||
actualLocalPath = QDir(tempPath).filePath(backupFilename);
|
||||
#else
|
||||
// На Desktop используем Documents (как обычный backup)
|
||||
QString documentsPath = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
|
||||
if (documentsPath.isEmpty()) {
|
||||
documentsPath = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
|
||||
}
|
||||
actualLocalPath = QDir(documentsPath).filePath(backupFilename);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Ensure local directory exists
|
||||
QFileInfo localFileInfo(actualLocalPath);
|
||||
QDir localDir = localFileInfo.dir();
|
||||
if (!localDir.exists()) {
|
||||
if (!localDir.mkpath(".")) {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to create local directory: %1").arg(localDir.path()), ErrorCode::InternalError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setProgress(25, tr("Starting file transfer..."));
|
||||
|
||||
ErrorCode error = m_serverController->downloadFileFromHost(credentials, remotePath, actualLocalPath);
|
||||
|
||||
if (error == ErrorCode::NoError) {
|
||||
// qDebug() << "Backup downloaded to:" << actualLocalPath;
|
||||
|
||||
#ifdef Q_OS_IOS
|
||||
QStringList filesToShare;
|
||||
filesToShare.append(actualLocalPath);
|
||||
IosController::Instance()->shareText(filesToShare);
|
||||
#endif
|
||||
|
||||
setStatus(Success);
|
||||
setProgress(100, tr("Backup downloaded successfully"));
|
||||
emit backupDownloaded(actualLocalPath);
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to download backup: error code %1").arg(static_cast<int>(error)), error);
|
||||
}
|
||||
}
|
||||
|
||||
void ServersBackupController::uploadBackup(const ServerCredentials &credentials,
|
||||
const QString &localPath)
|
||||
{
|
||||
if (m_status == InProgress) {
|
||||
emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if local file exists
|
||||
QFileInfo localFileInfo(localPath);
|
||||
if (!localFileInfo.exists()) {
|
||||
emit errorOccurred(tr("Local file does not exist: %1").arg(localPath), ErrorCode::InternalError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localFileInfo.isFile()) {
|
||||
emit errorOccurred(tr("Path is not a file: %1").arg(localPath), ErrorCode::InternalError);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(InProgress);
|
||||
setProgress(0, tr("Uploading backup..."));
|
||||
|
||||
// Construct remote file path with filename from local path
|
||||
QString filename = localFileInfo.fileName();
|
||||
QString remotePath = QString("%1/%2").arg(m_backupDir, filename);
|
||||
|
||||
setProgress(25, tr("Starting file transfer..."));
|
||||
|
||||
ErrorCode error = m_serverController->uploadFileToHostPublic(credentials, localPath, remotePath,
|
||||
libssh::ScpOverwriteMode::ScpOverwriteExisting);
|
||||
|
||||
if (error == ErrorCode::NoError) {
|
||||
setStatus(Success);
|
||||
setProgress(100, tr("Backup uploaded successfully"));
|
||||
emit backupUploaded(remotePath);
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to upload backup: error code %1").arg(static_cast<int>(error)), error);
|
||||
}
|
||||
}
|
||||
|
||||
void ServersBackupController::deleteBackup(const ServerCredentials &credentials,
|
||||
const QString &backupFilename)
|
||||
{
|
||||
if (m_status == InProgress) {
|
||||
emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(InProgress);
|
||||
setProgress(0, tr("Deleting backup..."));
|
||||
|
||||
QString script = QString("sudo rm -f %1/%2").arg(m_backupDir).arg(backupFilename);
|
||||
|
||||
m_currentOutput.clear();
|
||||
m_currentError.clear();
|
||||
|
||||
auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdOut(data, m_currentOutput);
|
||||
};
|
||||
|
||||
auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode {
|
||||
Q_UNUSED(client);
|
||||
return handleStdErr(data, m_currentError);
|
||||
};
|
||||
|
||||
ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr);
|
||||
|
||||
if (error == ErrorCode::NoError) {
|
||||
setStatus(Success);
|
||||
setProgress(100, tr("Backup deleted"));
|
||||
} else {
|
||||
setStatus(Failed);
|
||||
emit errorOccurred(tr("Failed to delete backup: %1").arg(m_currentError), error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ВСТРОЕННЫЕ BASH СКРИПТЫ
|
||||
// ============================================================================
|
||||
|
||||
QString ServersBackupController::getBackupScript() const
|
||||
{
|
||||
// Упрощенная версия bash скрипта, встроенная в C++
|
||||
return QString(R"(
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
BACKUP_DIR=%1
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_SUBDIR="$BACKUP_DIR/backup_$TIMESTAMP"
|
||||
|
||||
echo "[INFO] Starting backup..."
|
||||
|
||||
# Создание директории
|
||||
mkdir -p "$BACKUP_SUBDIR"
|
||||
|
||||
# Список контейнеров Amnezia
|
||||
CONTAINERS=(
|
||||
"amnezia-awg"
|
||||
"amnezia-awg2"
|
||||
"amnezia-openvpn"
|
||||
"amnezia-xray"
|
||||
"amnezia-wireguard"
|
||||
"amnezia-ipsec"
|
||||
"amnezia-cloak"
|
||||
"amnezia-shadowsocks"
|
||||
)
|
||||
|
||||
# Backup каждого контейнера (включая остановленные)
|
||||
for container in "${CONTAINERS[@]}"; do
|
||||
if sudo docker ps -a --format '{{.Names}}' | grep -q "^$container$"; then
|
||||
echo "[INFO] Backing up $container..."
|
||||
mkdir -p "$BACKUP_SUBDIR/$container"
|
||||
|
||||
# Копируем /opt/amnezia
|
||||
sudo docker cp "$container:/opt/amnezia" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true
|
||||
|
||||
# Сохраняем метаданные
|
||||
sudo docker inspect "$container" > "$BACKUP_SUBDIR/$container/container_inspect.json" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Создание архива
|
||||
cd "$BACKUP_DIR"
|
||||
tar -czf "backup_$TIMESTAMP.tar.gz" "backup_$TIMESTAMP" 2>/dev/null
|
||||
rm -rf "$BACKUP_SUBDIR"
|
||||
|
||||
echo "[INFO] Backup created: backup_$TIMESTAMP.tar.gz"
|
||||
)").arg(m_backupDir);
|
||||
}
|
||||
|
||||
QString ServersBackupController::getContainerBackupScript(DockerContainer container) const
|
||||
{
|
||||
QString containerName = ContainerProps::containerToString(container);
|
||||
|
||||
// Backup конкретного контейнера напрямую через docker cp
|
||||
return QString(R"(
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
BACKUP_DIR=%1
|
||||
CONTAINER_NAME=%2
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_SUBDIR="$BACKUP_DIR/backup_$TIMESTAMP"
|
||||
|
||||
echo "[INFO] Starting backup for container: $CONTAINER_NAME..."
|
||||
|
||||
# Проверка существования контейнера
|
||||
if ! sudo docker ps -a --format '{{.Names}}' | grep -q "^$CONTAINER_NAME$"; then
|
||||
echo "[ERROR] Container $CONTAINER_NAME does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Создание директории
|
||||
mkdir -p "$BACKUP_SUBDIR/$CONTAINER_NAME"
|
||||
|
||||
# Backup конфигураций из контейнера напрямую
|
||||
echo "[INFO] Copying /opt/amnezia from container..."
|
||||
sudo docker cp "$CONTAINER_NAME:/opt/amnezia" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || {
|
||||
echo "[WARN] Failed to copy /opt/amnezia, trying alternative paths..."
|
||||
# Альтернативные пути для разных типов контейнеров
|
||||
sudo docker cp "$CONTAINER_NAME:/etc/openvpn" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || true
|
||||
sudo docker cp "$CONTAINER_NAME:/etc/wireguard" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || true
|
||||
sudo docker cp "$CONTAINER_NAME:/etc/ipsec.d" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || true
|
||||
sudo docker cp "$CONTAINER_NAME:/etc/xray" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Сохранение метаданных контейнера
|
||||
echo "[INFO] Saving container metadata..."
|
||||
sudo docker inspect "$CONTAINER_NAME" > "$BACKUP_SUBDIR/$CONTAINER_NAME/container_inspect.json" 2>/dev/null || true
|
||||
|
||||
# Сохранение конфигурации сети
|
||||
sudo docker network inspect $(sudo docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$CONTAINER_NAME" | awk '{print $1}') \
|
||||
> "$BACKUP_SUBDIR/$CONTAINER_NAME/network_config.json" 2>/dev/null || true
|
||||
|
||||
# Создание архива
|
||||
cd "$BACKUP_DIR"
|
||||
tar -czf "backup_$TIMESTAMP.tar.gz" "backup_$TIMESTAMP" 2>/dev/null
|
||||
rm -rf "$BACKUP_SUBDIR"
|
||||
|
||||
echo "[INFO] Backup created: backup_$TIMESTAMP.tar.gz"
|
||||
)").arg(m_backupDir).arg(containerName);
|
||||
}
|
||||
|
||||
QString ServersBackupController::getContainersBackupScript(const QList<DockerContainer> &containers) const
|
||||
{
|
||||
QString containersList;
|
||||
for (const DockerContainer &container : containers) {
|
||||
QString containerName = ContainerProps::containerToString(container);
|
||||
containersList += QString("\"%1\" ").arg(containerName);
|
||||
}
|
||||
|
||||
return QString(R"(
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
BACKUP_DIR=%1
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_SUBDIR="$BACKUP_DIR/backup_$TIMESTAMP"
|
||||
|
||||
echo "[INFO] Starting backup for containers..."
|
||||
|
||||
# Создание директории
|
||||
mkdir -p "$BACKUP_SUBDIR"
|
||||
|
||||
# Список контейнеров для backup
|
||||
CONTAINERS=(%2)
|
||||
|
||||
# Backup каждого контейнера
|
||||
for container in "${CONTAINERS[@]}"; do
|
||||
if sudo docker ps -a --format '{{.Names}}' | grep -q "^$container$"; then
|
||||
echo "[INFO] Backing up $container..."
|
||||
mkdir -p "$BACKUP_SUBDIR/$container"
|
||||
|
||||
# Копируем /opt/amnezia напрямую из контейнера
|
||||
sudo docker cp "$container:/opt/amnezia" "$BACKUP_SUBDIR/$container/" 2>/dev/null || {
|
||||
echo "[WARN] Failed to copy /opt/amnezia from $container, trying alternative paths..."
|
||||
sudo docker cp "$container:/etc/openvpn" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true
|
||||
sudo docker cp "$container:/etc/wireguard" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true
|
||||
sudo docker cp "$container:/etc/ipsec.d" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true
|
||||
sudo docker cp "$container:/etc/xray" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Сохраняем метаданные
|
||||
sudo docker inspect "$container" > "$BACKUP_SUBDIR/$container/container_inspect.json" 2>/dev/null || true
|
||||
else
|
||||
echo "[WARN] Container $container does not exist, skipping..."
|
||||
fi
|
||||
done
|
||||
|
||||
# Создание архива
|
||||
cd "$BACKUP_DIR"
|
||||
tar -czf "backup_$TIMESTAMP.tar.gz" "backup_$TIMESTAMP" 2>/dev/null
|
||||
rm -rf "$BACKUP_SUBDIR"
|
||||
|
||||
echo "[INFO] Backup created: backup_$TIMESTAMP.tar.gz"
|
||||
)").arg(m_backupDir).arg(containersList.trimmed());
|
||||
}
|
||||
|
||||
QString ServersBackupController::getRestoreScript(const QString &backupFilename,
|
||||
const QStringList &containers) const
|
||||
{
|
||||
Q_UNUSED(containers); // TODO: Использовать для выборочного восстановления
|
||||
|
||||
return QString(R"(
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
BACKUP_DIR=%1
|
||||
BACKUP_FILE=%2
|
||||
TEMP_DIR="/tmp/amnezia_restore_$$"
|
||||
|
||||
echo "[INFO] Starting restore from $BACKUP_FILE..."
|
||||
|
||||
# Извлечение backup
|
||||
mkdir -p "$TEMP_DIR"
|
||||
tar -xzf "$BACKUP_DIR/$BACKUP_FILE" -C "$TEMP_DIR" 2>/dev/null
|
||||
|
||||
BACKUP_SUBDIR=$(ls -d "$TEMP_DIR"/backup_* 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$BACKUP_SUBDIR" ]; then
|
||||
echo "[ERROR] Failed to extract backup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Восстановление каждого контейнера
|
||||
for container_dir in "$BACKUP_SUBDIR"/*; do
|
||||
if [ ! -d "$container_dir" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
container_name=$(basename "$container_dir")
|
||||
|
||||
if sudo docker ps -a --format '{{.Names}}' | grep -q "^$container_name$"; then
|
||||
echo "[INFO] Restoring $container_name..."
|
||||
|
||||
# Остановка контейнера
|
||||
sudo docker stop "$container_name" 2>/dev/null || true
|
||||
|
||||
# Восстановление /opt/amnezia
|
||||
if [ -d "$container_dir/amnezia" ]; then
|
||||
sudo docker cp "$container_dir/amnezia" "$container_name:/opt/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Запуск контейнера
|
||||
sudo docker start "$container_name" 2>/dev/null || true
|
||||
|
||||
echo "[INFO] $container_name restored"
|
||||
fi
|
||||
done
|
||||
|
||||
# Очистка
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo "[INFO] Restore completed successfully"
|
||||
)").arg(m_backupDir).arg(backupFilename);
|
||||
}
|
||||
|
||||
QString ServersBackupController::getCheckStatusScript() const
|
||||
{
|
||||
return QString(R"SCRIPT(
|
||||
#!/bin/bash
|
||||
|
||||
BACKUP_DIR=%1
|
||||
|
||||
echo "Backup directory: $BACKUP_DIR"
|
||||
|
||||
# Проверка наличия backup
|
||||
BACKUPS=$(ls -t "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null | wc -l)
|
||||
echo "Total backups: $BACKUPS"
|
||||
|
||||
if [ "$BACKUPS" -gt 0 ]; then
|
||||
# Информация о последнем backup
|
||||
LATEST=$(ls -t "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null | head -1)
|
||||
if [ -n "$LATEST" ]; then
|
||||
echo "Latest backup: $(basename "$LATEST")"
|
||||
echo "Size: $(du -h "$LATEST" | cut -f1)"
|
||||
echo "Modified: $(stat -c %y "$LATEST" 2>/dev/null | cut -d'.' -f1)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Проверка запущенных контейнеров
|
||||
echo "Running containers:"
|
||||
sudo docker ps --filter "name=amnezia-" --format '{{.Names}}' 2>/dev/null
|
||||
|
||||
echo "Status check completed"
|
||||
)SCRIPT").arg(m_backupDir);
|
||||
}
|
||||
|
||||
QString ServersBackupController::getListBackupsScript() const
|
||||
{
|
||||
return QString(R"(
|
||||
#!/bin/bash
|
||||
|
||||
BACKUP_DIR=%1
|
||||
|
||||
ls -lht "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null || echo "No backups found"
|
||||
)").arg(m_backupDir);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ПАРСИНГ ВЫВОДА
|
||||
// ============================================================================
|
||||
|
||||
QList<ServersBackupController::BackupInfo> ServersBackupController::parseBackupList(const QString &output)
|
||||
{
|
||||
QList<BackupInfo> backups;
|
||||
|
||||
QStringList lines = output.split('\n', Qt::SkipEmptyParts);
|
||||
|
||||
// Парсим вывод ls -lht
|
||||
QRegularExpression re("^-.*\\s+(\\d+)\\s+\\w+\\s+\\d+\\s+([\\d:]+)\\s+(.+backup_(\\d{8}_\\d{6})\\.tar\\.gz)$");
|
||||
|
||||
for (const QString &line : lines) {
|
||||
QRegularExpressionMatch match = re.match(line);
|
||||
if (match.hasMatch()) {
|
||||
BackupInfo info;
|
||||
info.size = match.captured(1).toLongLong();
|
||||
info.filename = QFileInfo(match.captured(3)).fileName();
|
||||
info.fullPath = match.captured(3);
|
||||
|
||||
// Парсим дату из имени файла
|
||||
QString dateStr = match.captured(4);
|
||||
info.createdAt = QDateTime::fromString(dateStr, "yyyyMMdd_HHmmss");
|
||||
info.isValid = true;
|
||||
|
||||
backups.append(info);
|
||||
}
|
||||
}
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
QJsonObject ServersBackupController::parseBackupStatus(const QString &output)
|
||||
{
|
||||
QJsonObject status;
|
||||
|
||||
// Парсим текстовый вывод
|
||||
status["raw_output"] = output;
|
||||
status["has_backups"] = output.contains("Total backups:");
|
||||
|
||||
// Извлекаем количество backup
|
||||
QRegularExpression reTotal("Total backups: (\\d+)");
|
||||
QRegularExpressionMatch matchTotal = reTotal.match(output);
|
||||
if (matchTotal.hasMatch()) {
|
||||
status["total_backups"] = matchTotal.captured(1).toInt();
|
||||
}
|
||||
|
||||
// Извлекаем информацию о последнем backup
|
||||
QRegularExpression reLatest("Latest backup: (.+)");
|
||||
QRegularExpressionMatch matchLatest = reLatest.match(output);
|
||||
if (matchLatest.hasMatch()) {
|
||||
status["latest_backup"] = matchLatest.captured(1);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ
|
||||
// ============================================================================
|
||||
|
||||
ErrorCode ServersBackupController::handleStdOut(const QString &data, QString &output)
|
||||
{
|
||||
output += data;
|
||||
qDebug().noquote() << "[BACKUP]" << data;
|
||||
|
||||
// Обновляем прогресс на основе вывода
|
||||
if (data.contains("Starting backup")) {
|
||||
setProgress(10, tr("Starting backup..."));
|
||||
} else if (data.contains("Backing up")) {
|
||||
setProgress(50, tr("Backing up containers..."));
|
||||
} else if (data.contains("Backup created")) {
|
||||
setProgress(90, tr("Finalizing..."));
|
||||
}
|
||||
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode ServersBackupController::handleStdErr(const QString &data, QString &error)
|
||||
{
|
||||
error += data;
|
||||
qDebug().noquote() << "[BACKUP ERROR]" << data;
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
void ServersBackupController::setStatus(BackupStatus status)
|
||||
{
|
||||
if (m_status != status) {
|
||||
m_status = status;
|
||||
emit statusChanged(status);
|
||||
}
|
||||
}
|
||||
|
||||
void ServersBackupController::setProgress(int percent, const QString &message)
|
||||
{
|
||||
emit progressChanged(percent, message);
|
||||
}
|
||||
|
||||
259
client/ui/controllers/serversBackupController.h
Normal file
259
client/ui/controllers/serversBackupController.h
Normal file
@@ -0,0 +1,259 @@
|
||||
#ifndef SERVERSBACKUPCONTROLLER_H
|
||||
#define SERVERSBACKUPCONTROLLER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QFileInfo>
|
||||
|
||||
#include "core/controllers/serverController.h"
|
||||
#include "core/defs.h"
|
||||
#include "containers/containers_defs.h"
|
||||
|
||||
using namespace amnezia;
|
||||
|
||||
/**
|
||||
* @brief Контроллер для управления backup конфигураций Amnezia VPN
|
||||
*
|
||||
* Использует существующий ServerController и libssh::Client из Amnezia
|
||||
* Bash скрипты встроены напрямую в C++ код
|
||||
* Поддерживает backup конкретных контейнеров напрямую через docker cp
|
||||
*
|
||||
* Полностью кроссплатформенный: Windows, macOS, Linux, iOS, Android
|
||||
*/
|
||||
class ServersBackupController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ServersBackupController(std::shared_ptr<Settings> settings, QObject *parent = nullptr);
|
||||
~ServersBackupController();
|
||||
|
||||
/**
|
||||
* @brief Информация о backup
|
||||
*/
|
||||
struct BackupInfo {
|
||||
QString filename;
|
||||
QString fullPath;
|
||||
QDateTime createdAt;
|
||||
qint64 size;
|
||||
bool isValid;
|
||||
QStringList containers;
|
||||
};
|
||||
|
||||
enum BackupStatus {
|
||||
Idle,
|
||||
InProgress,
|
||||
Success,
|
||||
Failed
|
||||
};
|
||||
Q_ENUM(BackupStatus)
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief Создать backup на сервере (всех контейнеров)
|
||||
* @param credentials Учетные данные сервера
|
||||
*/
|
||||
void createBackup(const ServerCredentials &credentials);
|
||||
|
||||
/**
|
||||
* @brief Создать backup конкретного контейнера
|
||||
* @param credentials Учетные данные сервера
|
||||
* @param container Тип контейнера для backup
|
||||
*/
|
||||
void createContainerBackup(const ServerCredentials &credentials, DockerContainer container);
|
||||
|
||||
/**
|
||||
* @brief Создать backup конкретного контейнера по имени
|
||||
* @param credentials Учетные данные сервера
|
||||
* @param containerName Имя контейнера (например "amnezia-awg")
|
||||
*/
|
||||
void createBackupByName(const ServerCredentials &credentials, const QString &containerName);
|
||||
|
||||
/**
|
||||
* @brief Создать backup нескольких контейнеров
|
||||
* @param credentials Учетные данные сервера
|
||||
* @param containers Список контейнеров для backup
|
||||
*/
|
||||
void createContainersBackup(const ServerCredentials &credentials, const QList<DockerContainer> &containers);
|
||||
|
||||
/**
|
||||
* @brief Получить список backup с сервера
|
||||
* @param credentials Учетные данные сервера
|
||||
*/
|
||||
void fetchBackupList(const ServerCredentials &credentials);
|
||||
|
||||
/**
|
||||
* @brief Восстановить из backup
|
||||
* @param credentials Учетные данные сервера
|
||||
* @param backupFilename Имя файла backup
|
||||
* @param containers Список контейнеров (пустой = все)
|
||||
*/
|
||||
void restoreBackup(const ServerCredentials &credentials,
|
||||
const QString &backupFilename,
|
||||
const QStringList &containers = QStringList());
|
||||
|
||||
/**
|
||||
* @brief Проверить состояние backup на сервере
|
||||
* @param credentials Учетные данные сервера
|
||||
*/
|
||||
void checkBackupStatus(const ServerCredentials &credentials);
|
||||
|
||||
/**
|
||||
* @brief Скачать backup на локальную машину
|
||||
* @param credentials Учетные данные сервера
|
||||
* @param backupFilename Имя файла backup
|
||||
* @param localPath Путь для сохранения
|
||||
*/
|
||||
void downloadBackup(const ServerCredentials &credentials,
|
||||
const QString &backupFilename,
|
||||
const QString &localPath);
|
||||
|
||||
/**
|
||||
* @brief Загрузить backup на сервер
|
||||
* @param credentials Учетные данные сервера
|
||||
* @param localPath Путь к локальному файлу
|
||||
*/
|
||||
void uploadBackup(const ServerCredentials &credentials,
|
||||
const QString &localPath);
|
||||
|
||||
/**
|
||||
* @brief Удалить backup с сервера
|
||||
* @param credentials Учетные данные сервера
|
||||
* @param backupFilename Имя файла backup
|
||||
*/
|
||||
void deleteBackup(const ServerCredentials &credentials,
|
||||
const QString &backupFilename);
|
||||
|
||||
/**
|
||||
* @brief Установить директорию backup на сервере
|
||||
*/
|
||||
void setBackupDirectory(const QString &directory);
|
||||
|
||||
/**
|
||||
* @brief Получить директорию backup
|
||||
*/
|
||||
QString backupDirectory() const { return m_backupDir; }
|
||||
|
||||
signals:
|
||||
/**
|
||||
* @brief Изменился статус операции
|
||||
*/
|
||||
void statusChanged(BackupStatus status);
|
||||
|
||||
/**
|
||||
* @brief Прогресс операции (0-100)
|
||||
*/
|
||||
void progressChanged(int percent, const QString &message);
|
||||
|
||||
/**
|
||||
* @brief Получен список backup
|
||||
*/
|
||||
void backupListReceived(const QList<BackupInfo> &backups);
|
||||
|
||||
/**
|
||||
* @brief Backup создан успешно
|
||||
*/
|
||||
void backupCreated(const QString &backupFilename);
|
||||
|
||||
/**
|
||||
* @brief Backup восстановлен успешно
|
||||
*/
|
||||
void backupRestored();
|
||||
|
||||
/**
|
||||
* @brief Backup скачан
|
||||
*/
|
||||
void backupDownloaded(const QString &localPath);
|
||||
|
||||
/**
|
||||
* @brief Backup загружен на сервер
|
||||
*/
|
||||
void backupUploaded(const QString &serverPath);
|
||||
|
||||
/**
|
||||
* @brief Получена информация о состоянии backup
|
||||
*/
|
||||
void backupStatusReceived(const QJsonObject &status);
|
||||
|
||||
/**
|
||||
* @brief Произошла ошибка
|
||||
*/
|
||||
void errorOccurred(const QString &errorMessage, ErrorCode errorCode);
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Получить bash скрипт для создания backup всех контейнеров
|
||||
*/
|
||||
QString getBackupScript() const;
|
||||
|
||||
/**
|
||||
* @brief Получить bash скрипт для создания backup конкретного контейнера
|
||||
* @param container Тип контейнера
|
||||
*/
|
||||
QString getContainerBackupScript(DockerContainer container) const;
|
||||
|
||||
/**
|
||||
* @brief Получить bash скрипт для создания backup нескольких контейнеров
|
||||
* @param containers Список контейнеров
|
||||
*/
|
||||
QString getContainersBackupScript(const QList<DockerContainer> &containers) const;
|
||||
|
||||
/**
|
||||
* @brief Получить bash скрипт для восстановления
|
||||
*/
|
||||
QString getRestoreScript(const QString &backupFilename, const QStringList &containers) const;
|
||||
|
||||
/**
|
||||
* @brief Получить bash скрипт для проверки состояния
|
||||
*/
|
||||
QString getCheckStatusScript() const;
|
||||
|
||||
/**
|
||||
* @brief Получить bash скрипт для списка backup
|
||||
*/
|
||||
QString getListBackupsScript() const;
|
||||
|
||||
/**
|
||||
* @brief Парсить список backup из вывода
|
||||
*/
|
||||
QList<BackupInfo> parseBackupList(const QString &output);
|
||||
|
||||
/**
|
||||
* @brief Парсить статус из вывода
|
||||
*/
|
||||
QJsonObject parseBackupStatus(const QString &output);
|
||||
|
||||
/**
|
||||
* @brief Обработать стандартный вывод
|
||||
*/
|
||||
ErrorCode handleStdOut(const QString &data, QString &output);
|
||||
|
||||
/**
|
||||
* @brief Обработать вывод ошибок
|
||||
*/
|
||||
ErrorCode handleStdErr(const QString &data, QString &error);
|
||||
|
||||
/**
|
||||
* @brief Установить статус
|
||||
*/
|
||||
void setStatus(BackupStatus status);
|
||||
|
||||
/**
|
||||
* @brief Установить прогресс
|
||||
*/
|
||||
void setProgress(int percent, const QString &message);
|
||||
|
||||
private:
|
||||
std::shared_ptr<Settings> m_settings;
|
||||
ServerController *m_serverController;
|
||||
BackupStatus m_status;
|
||||
QString m_backupDir;
|
||||
QString m_currentOutput;
|
||||
QString m_currentError;
|
||||
};
|
||||
|
||||
#endif // SERVERSBACKUPCONTROLLER_H
|
||||
|
||||
@@ -19,6 +19,7 @@ PageType {
|
||||
signal lastItemTabClickedSignal()
|
||||
|
||||
property bool isServerWithWriteAccess: ServersModel.isProcessedServerHasWriteAccess()
|
||||
property string selectedContainerValue: "all"
|
||||
|
||||
Connections {
|
||||
target: InstallController
|
||||
@@ -75,10 +76,148 @@ PageType {
|
||||
delegate: ColumnLayout {
|
||||
width: listView.width
|
||||
|
||||
// Кастомная секция для backup
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: isVisible && isBackupSection
|
||||
|
||||
spacing: 16
|
||||
|
||||
Header2Type {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
Layout.topMargin: 8
|
||||
|
||||
headerText: qsTr("Backup & Restore")
|
||||
}
|
||||
|
||||
ParagraphTextType {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
|
||||
text: qsTr("Create and restore server configuration backups")
|
||||
color: AmneziaStyle.color.mutedGray
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
spacing: 12
|
||||
|
||||
LabelTextType {
|
||||
text: qsTr("Select containers:")
|
||||
}
|
||||
|
||||
DropDownType {
|
||||
id: containerSelector
|
||||
Layout.fillWidth: true
|
||||
|
||||
objectName: "containerSelector"
|
||||
descriptionText: qsTr("Choose which containers to backup")
|
||||
headerText: qsTr("Containers")
|
||||
|
||||
text: "All containers"
|
||||
|
||||
drawerHeight: 0.4375
|
||||
drawerParent: root
|
||||
|
||||
listView: ListViewWithRadioButtonType {
|
||||
id: containerSelectorListView
|
||||
rootWidth: root.width
|
||||
|
||||
model: ListModel {
|
||||
id: containersModel
|
||||
ListElement { name: "All containers"; value: "all" }
|
||||
ListElement { name: "OpenVPN"; value: "amnezia-openvpn" }
|
||||
ListElement { name: "WireGuard"; value: "amnezia-wireguard" }
|
||||
ListElement { name: "AmneziaWG (legacy)"; value: "amnezia-awg" }
|
||||
ListElement { name: "AmneziaWG 2"; value: "amnezia-awg2" }
|
||||
ListElement { name: "Xray"; value: "amnezia-xray" }
|
||||
ListElement { name: "IKEv2"; value: "amnezia-ipsec" }
|
||||
ListElement { name: "Cloak"; value: "amnezia-cloak" }
|
||||
ListElement { name: "ShadowSocks"; value: "amnezia-shadowsocks" }
|
||||
}
|
||||
|
||||
clickedFunction: function() {
|
||||
if (containerSelectorListView.selectedText) {
|
||||
containerSelector.text = containerSelectorListView.selectedText
|
||||
}
|
||||
// Сохранить выбранное значение
|
||||
var index = containerSelectorListView.selectedIndex
|
||||
if (index >= 0 && index < containersModel.count) {
|
||||
root.selectedContainerValue = containersModel.get(index).value
|
||||
}
|
||||
containerSelector.closeTriggered()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
containerSelectorListView.selectedIndex = 0
|
||||
root.selectedContainerValue = "all"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
BasicButtonType {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Create Backup")
|
||||
|
||||
clickedFunc: function() {
|
||||
createBackup(false) // false = не скачивать автоматически
|
||||
}
|
||||
}
|
||||
|
||||
BasicButtonType {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Backup & Download")
|
||||
|
||||
clickedFunc: function() {
|
||||
createBackup(true) // true = скачать после создания
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BasicButtonType {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Restore Backup")
|
||||
defaultColor: AmneziaStyle.color.transparent
|
||||
hoveredColor: Qt.rgba(1, 1, 1, 0.08)
|
||||
pressedColor: Qt.rgba(1, 1, 1, 0.12)
|
||||
disabledColor: AmneziaStyle.color.mutedGray
|
||||
textColor: AmneziaStyle.color.goldenApricot
|
||||
|
||||
clickedFunc: function() {
|
||||
restoreBackup()
|
||||
}
|
||||
}
|
||||
|
||||
BasicButtonType {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Manage Backups")
|
||||
defaultColor: AmneziaStyle.color.transparent
|
||||
hoveredColor: Qt.rgba(1, 1, 1, 0.08)
|
||||
pressedColor: Qt.rgba(1, 1, 1, 0.12)
|
||||
disabledColor: AmneziaStyle.color.mutedGray
|
||||
textColor: AmneziaStyle.color.goldenApricot
|
||||
|
||||
clickedFunc: function() {
|
||||
manageBackups()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обычные кнопки для других действий
|
||||
LabelWithButtonType {
|
||||
Layout.fillWidth: true
|
||||
|
||||
visible: isVisible
|
||||
visible: isVisible && !isBackupSection
|
||||
|
||||
text: title
|
||||
descriptionText: description
|
||||
@@ -97,6 +236,7 @@ PageType {
|
||||
|
||||
property list<QtObject> serverActions: [
|
||||
check,
|
||||
backupSection,
|
||||
reboot,
|
||||
remove,
|
||||
clear,
|
||||
@@ -108,6 +248,7 @@ PageType {
|
||||
id: check
|
||||
|
||||
property bool isVisible: root.isServerWithWriteAccess
|
||||
readonly property bool isBackupSection: false
|
||||
readonly property string title: qsTr("Check the server for previously installed Amnezia services")
|
||||
readonly property string description: qsTr("Add them to the application if they were not displayed")
|
||||
readonly property var tColor: AmneziaStyle.color.paleGray
|
||||
@@ -118,10 +259,22 @@ PageType {
|
||||
}
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: backupSection
|
||||
|
||||
property bool isVisible: root.isServerWithWriteAccess
|
||||
readonly property bool isBackupSection: true
|
||||
readonly property string title: ""
|
||||
readonly property string description: ""
|
||||
readonly property var tColor: AmneziaStyle.color.paleGray
|
||||
readonly property var clickedHandler: function() {}
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: reboot
|
||||
|
||||
property bool isVisible: root.isServerWithWriteAccess
|
||||
readonly property bool isBackupSection: false
|
||||
readonly property string title: qsTr("Reboot server")
|
||||
readonly property string description: ""
|
||||
readonly property var tColor: AmneziaStyle.color.vibrantRed
|
||||
@@ -152,6 +305,7 @@ PageType {
|
||||
id: remove
|
||||
|
||||
property bool isVisible: true
|
||||
readonly property bool isBackupSection: false
|
||||
readonly property string title: qsTr("Remove server from application")
|
||||
readonly property string description: ""
|
||||
readonly property var tColor: AmneziaStyle.color.vibrantRed
|
||||
@@ -182,6 +336,7 @@ PageType {
|
||||
id: clear
|
||||
|
||||
property bool isVisible: root.isServerWithWriteAccess
|
||||
readonly property bool isBackupSection: false
|
||||
readonly property string title: qsTr("Clear server from Amnezia software")
|
||||
readonly property string description: ""
|
||||
readonly property var tColor: AmneziaStyle.color.vibrantRed
|
||||
@@ -211,6 +366,7 @@ PageType {
|
||||
id: reset
|
||||
|
||||
property bool isVisible: ServersModel.getProcessedServerData("isServerFromTelegramApi")
|
||||
readonly property bool isBackupSection: false
|
||||
readonly property string title: qsTr("Reset API config")
|
||||
readonly property string description: ""
|
||||
readonly property var tColor: AmneziaStyle.color.vibrantRed
|
||||
@@ -241,6 +397,7 @@ PageType {
|
||||
id: switch_to_premium
|
||||
|
||||
property bool isVisible: ServersModel.getProcessedServerData("isServerFromTelegramApi") && ServersModel.processedServerIsPremium
|
||||
readonly property bool isBackupSection: false
|
||||
readonly property string title: qsTr("Switch to the new Amnezia Premium subscription")
|
||||
readonly property string description: ""
|
||||
readonly property var tColor: AmneziaStyle.color.vibrantRed
|
||||
@@ -249,4 +406,171 @@ PageType {
|
||||
ApiPremV1MigrationController.showMigrationDrawer()
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Backup Functions ============
|
||||
|
||||
function getServerCredentials() {
|
||||
var index = ServersModel.processedIndex
|
||||
return ServersModel.getServerCredentials(index)
|
||||
}
|
||||
|
||||
function getSelectedContainerValue() {
|
||||
return root.selectedContainerValue
|
||||
}
|
||||
|
||||
property bool downloadAfterCreate: false
|
||||
|
||||
function createBackup(shouldDownload) {
|
||||
downloadAfterCreate = shouldDownload || false
|
||||
|
||||
var headerText = shouldDownload ?
|
||||
qsTr("Create backup and download to device?") :
|
||||
qsTr("Create server configuration backup?")
|
||||
var descriptionText = shouldDownload ?
|
||||
qsTr("Backup will be created on server and automatically downloaded to your device") :
|
||||
qsTr("This will create a backup of your server containers configuration on the server")
|
||||
var yesButtonText = qsTr("Create")
|
||||
var noButtonText = qsTr("Cancel")
|
||||
|
||||
var yesButtonFunction = function() {
|
||||
PageController.showBusyIndicator(true)
|
||||
|
||||
var credentials = getServerCredentials()
|
||||
var containerValue = getSelectedContainerValue()
|
||||
|
||||
if (containerValue === "all") {
|
||||
ServersBackupController.createBackup(credentials)
|
||||
} else {
|
||||
ServersBackupController.createBackupByName(credentials, containerValue)
|
||||
}
|
||||
}
|
||||
var noButtonFunction = function() {}
|
||||
|
||||
showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction)
|
||||
}
|
||||
|
||||
property string selectedBackupForRestore: ""
|
||||
|
||||
function restoreBackup() {
|
||||
// Шаг 1: Выбрать backup файл на устройстве
|
||||
// На Android используем "*.gz" для MIME типа application/gzip
|
||||
// На Desktop используем "Backup files (*.tar.gz *.backup)"
|
||||
var filter = GC.isMobile() ? "*.gz" : "Backup files (*.tar.gz *.backup)"
|
||||
var localPath = SystemController.getFileName(
|
||||
qsTr("Select Backup to Restore"),
|
||||
filter,
|
||||
"",
|
||||
false, // openMode
|
||||
""
|
||||
)
|
||||
|
||||
if (!localPath || localPath.length === 0) {
|
||||
return // Пользователь отменил
|
||||
}
|
||||
|
||||
selectedBackupForRestore = localPath
|
||||
|
||||
// Шаг 2: Подтверждение восстановления
|
||||
var headerText = qsTr("Restore server configuration?")
|
||||
var descriptionText = qsTr("Selected backup will be uploaded to server and restored. Current configuration will be overwritten. All containers will be restarted.")
|
||||
var yesButtonText = qsTr("Restore")
|
||||
var noButtonText = qsTr("Cancel")
|
||||
|
||||
var yesButtonFunction = function() {
|
||||
PageController.showBusyIndicator(true)
|
||||
|
||||
var credentials = getServerCredentials()
|
||||
|
||||
// Загрузить backup на сервер
|
||||
ServersBackupController.uploadBackup(credentials, selectedBackupForRestore)
|
||||
}
|
||||
var noButtonFunction = function() {
|
||||
selectedBackupForRestore = ""
|
||||
}
|
||||
|
||||
showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction)
|
||||
}
|
||||
|
||||
function manageBackups() {
|
||||
PageController.showNotificationMessage(qsTr("Backup management page will be available in the next update"))
|
||||
}
|
||||
|
||||
// ============ Backup Controller Connections ============
|
||||
|
||||
property string lastCreatedBackupFilename: ""
|
||||
property string lastUploadedBackupFilename: ""
|
||||
|
||||
Connections {
|
||||
target: ServersBackupController
|
||||
|
||||
function onBackupCreated(backupFilename) {
|
||||
lastCreatedBackupFilename = backupFilename
|
||||
|
||||
if (downloadAfterCreate) {
|
||||
// Скачать backup на устройство
|
||||
var credentials = getServerCredentials()
|
||||
|
||||
// Автоматически скачиваем:
|
||||
var localPath = backupFilename
|
||||
PageController.showNotificationMessage(qsTr("Backup created. Downloading to device..."))
|
||||
ServersBackupController.downloadBackup(credentials, backupFilename, localPath)
|
||||
|
||||
downloadAfterCreate = false
|
||||
} else {
|
||||
PageController.showBusyIndicator(false)
|
||||
PageController.showNotificationMessage(qsTr("Backup created successfully: %1").arg(backupFilename))
|
||||
}
|
||||
}
|
||||
|
||||
function onBackupDownloaded(localPath) {
|
||||
PageController.showBusyIndicator(false)
|
||||
console.log("Backup downloaded to:", localPath)
|
||||
|
||||
// Удалить backup с сервера после успешного скачивания
|
||||
if (lastCreatedBackupFilename && lastCreatedBackupFilename.length > 0) {
|
||||
var credentials = getServerCredentials()
|
||||
ServersBackupController.deleteBackup(credentials, lastCreatedBackupFilename)
|
||||
console.log("Deleting backup from server:", lastCreatedBackupFilename)
|
||||
}
|
||||
|
||||
PageController.showNotificationMessage(qsTr("Backup downloaded successfully!\n\nSaved to:\n%1").arg(localPath))
|
||||
}
|
||||
|
||||
function onBackupUploaded(serverPath) {
|
||||
// Backup загружен на сервер, теперь восстановить
|
||||
PageController.showNotificationMessage(qsTr("Backup uploaded. Restoring configuration..."))
|
||||
|
||||
// Извлечь имя файла из пути и сохранить для последующего удаления
|
||||
var backupFilename = serverPath.split('/').pop()
|
||||
lastUploadedBackupFilename = backupFilename
|
||||
|
||||
var credentials = getServerCredentials()
|
||||
ServersBackupController.restoreBackup(credentials, backupFilename)
|
||||
}
|
||||
|
||||
function onBackupRestored() {
|
||||
PageController.showBusyIndicator(false)
|
||||
|
||||
// Удалить backup с сервера после успешного восстановления
|
||||
/* if (lastUploadedBackupFilename && lastUploadedBackupFilename.length > 0) {
|
||||
var credentials = getServerCredentials()
|
||||
ServersBackupController.deleteBackup(credentials, lastUploadedBackupFilename)
|
||||
console.log("Deleting backup from server after restore:", lastUploadedBackupFilename)
|
||||
lastUploadedBackupFilename = ""
|
||||
}*/
|
||||
|
||||
selectedBackupForRestore = ""
|
||||
PageController.showNotificationMessage(qsTr("Backup restored successfully! Containers are restarting..."))
|
||||
}
|
||||
|
||||
function onProgressChanged(percent, message) {
|
||||
// TODO: Show progress in UI
|
||||
console.log("Backup progress:", percent, "%", message)
|
||||
}
|
||||
|
||||
function onErrorOccurred(errorMessage, errorCode) {
|
||||
PageController.showBusyIndicator(false)
|
||||
PageController.showErrorMessage(qsTr("Backup error: %1").arg(errorMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user