mirror of https://github.com/halo-dev/halo
Merge pull request #6486 from JohnNiang/feat/restore-from-folder
Add support for restoring from backup rootpull/6491/head
commit
ad81f6dcb7
|
@ -5239,55 +5239,6 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/apis/api.console.migration.halo.run/v1alpha1/backups/{name}/files/{filename}": {
|
||||
"get": {
|
||||
"operationId": "DownloadBackups",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Backup name.",
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Backup filename.",
|
||||
"in": "path",
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {},
|
||||
"tags": [
|
||||
"MigrationV1alpha1Console"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/api.console.migration.halo.run/v1alpha1/restorations": {
|
||||
"post": {
|
||||
"description": "Restore backup by uploading file or providing download link or backup name.",
|
||||
"operationId": "RestoreBackup",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RestoreRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {},
|
||||
"tags": [
|
||||
"MigrationV1alpha1Console"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/api.content.halo.run/v1alpha1/categories": {
|
||||
"get": {
|
||||
"description": "Lists categories.",
|
||||
|
@ -7411,6 +7362,79 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/apis/console.api.migration.halo.run/v1alpha1/backup-files": {
|
||||
"get": {
|
||||
"description": "Get backup files from backup root.",
|
||||
"operationId": "getBackupFiles",
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BackupFile"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "default response"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"MigrationV1alpha1Console"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/console.api.migration.halo.run/v1alpha1/backups/{name}/files/{filename}": {
|
||||
"get": {
|
||||
"operationId": "DownloadBackups",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Backup name.",
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Backup filename.",
|
||||
"in": "path",
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {},
|
||||
"tags": [
|
||||
"MigrationV1alpha1Console"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/console.api.migration.halo.run/v1alpha1/restorations": {
|
||||
"post": {
|
||||
"description": "Restore backup by uploading file or providing download link or backup name.",
|
||||
"operationId": "RestoreBackup",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RestoreRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {},
|
||||
"tags": [
|
||||
"MigrationV1alpha1Console"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/console.api.notification.halo.run/v1alpha1/notifiers/default-email-notifier/verify-connection": {
|
||||
"post": {
|
||||
"description": "Verify email sender config.",
|
||||
|
@ -15300,6 +15324,22 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"BackupFile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastModifiedTime": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BackupList": {
|
||||
"required": [
|
||||
"first",
|
||||
|
@ -20684,6 +20724,10 @@
|
|||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Filename of backup file in backups root."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -3106,6 +3106,79 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/apis/console.api.migration.halo.run/v1alpha1/backup-files": {
|
||||
"get": {
|
||||
"description": "Get backup files from backup root.",
|
||||
"operationId": "getBackupFiles",
|
||||
"responses": {
|
||||
"default": {
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BackupFile"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "default response"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"MigrationV1alpha1Console"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/console.api.migration.halo.run/v1alpha1/backups/{name}/files/{filename}": {
|
||||
"get": {
|
||||
"operationId": "DownloadBackups",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Backup name.",
|
||||
"in": "path",
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Backup filename.",
|
||||
"in": "path",
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {},
|
||||
"tags": [
|
||||
"MigrationV1alpha1Console"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/console.api.migration.halo.run/v1alpha1/restorations": {
|
||||
"post": {
|
||||
"description": "Restore backup by uploading file or providing download link or backup name.",
|
||||
"operationId": "RestoreBackup",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RestoreRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {},
|
||||
"tags": [
|
||||
"MigrationV1alpha1Console"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/console.api.notification.halo.run/v1alpha1/notifiers/default-email-notifier/verify-connection": {
|
||||
"post": {
|
||||
"description": "Verify email sender config.",
|
||||
|
@ -3376,6 +3449,22 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"BackupFile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastModifiedTime": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Category": {
|
||||
"required": [
|
||||
"apiVersion",
|
||||
|
@ -5405,6 +5494,27 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"RestoreRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"backupName": {
|
||||
"type": "string",
|
||||
"description": "Backup metadata name."
|
||||
},
|
||||
"downloadUrl": {
|
||||
"type": "string",
|
||||
"description": "Remote backup HTTP URL."
|
||||
},
|
||||
"file": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Filename of backup file in backups root."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RevertSnapshotForPostParam": {
|
||||
"required": [
|
||||
"snapshotName"
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
package run.halo.app.migration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Backup file.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
@Data
|
||||
public class BackupFile {
|
||||
|
||||
@JsonIgnore
|
||||
private Path path;
|
||||
|
||||
/**
|
||||
* Filename of backup file.
|
||||
*/
|
||||
private String filename;
|
||||
|
||||
/**
|
||||
* Size of backup file.
|
||||
*/
|
||||
private long size;
|
||||
|
||||
/**
|
||||
* Last modified time of backup file.
|
||||
*/
|
||||
private Instant lastModifiedTime;
|
||||
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package run.halo.app.migration;
|
||||
|
||||
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED;
|
||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
||||
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||
|
@ -12,10 +13,12 @@ import java.net.MalformedURLException;
|
|||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.data.util.Optionals;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.multipart.FilePart;
|
||||
|
@ -24,7 +27,9 @@ import org.springframework.http.codec.multipart.Part;
|
|||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
@ -55,6 +60,15 @@ public class MigrationEndpoint implements CustomEndpoint {
|
|||
public RouterFunction<ServerResponse> endpoint() {
|
||||
var tag = "MigrationV1alpha1Console";
|
||||
return SpringdocRouteBuilder.route()
|
||||
.GET("/backup-files",
|
||||
this::getBackups,
|
||||
builder -> builder.operationId("getBackupFiles")
|
||||
.tag(tag)
|
||||
.description("Get backup files from backup root.")
|
||||
.response(responseBuilder()
|
||||
.implementationArray(BackupFile.class)
|
||||
)
|
||||
)
|
||||
.GET("/backups/{name}/files/{filename}",
|
||||
request -> {
|
||||
var name = request.pathVariable("name");
|
||||
|
@ -86,7 +100,7 @@ public class MigrationEndpoint implements CustomEndpoint {
|
|||
var content = getContent(restoreRequest)
|
||||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
|
||||
"Please upload a file "
|
||||
+ "or provide a download link or backup name.")));
|
||||
+ "or provide a download link or backup name.")));
|
||||
return migrationService.restore(content);
|
||||
})
|
||||
.then(Mono.defer(
|
||||
|
@ -95,7 +109,7 @@ public class MigrationEndpoint implements CustomEndpoint {
|
|||
builder -> builder
|
||||
.tag(tag)
|
||||
.description("Restore backup by uploading file "
|
||||
+ "or providing download link or backup name.")
|
||||
+ "or providing download link or backup name.")
|
||||
.operationId("RestoreBackup")
|
||||
.requestBody(requestBodyBuilder()
|
||||
.required(true)
|
||||
|
@ -108,8 +122,22 @@ public class MigrationEndpoint implements CustomEndpoint {
|
|||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getBackups(ServerRequest request) {
|
||||
var backupFiles = migrationService.getBackupFiles();
|
||||
return ServerResponse.ok().body(backupFiles, BackupFile.class);
|
||||
}
|
||||
|
||||
private Flux<DataBuffer> getContent(RestoreRequest request) {
|
||||
var downloadContent = request.getDownloadUrl()
|
||||
Supplier<Optional<Flux<DataBuffer>>> contentFromFilename = () ->
|
||||
request.getFilename().map(filename -> migrationService.getBackupFile(filename)
|
||||
.map(BackupFile::getPath)
|
||||
.flatMapMany(
|
||||
path -> DataBufferUtils.read(
|
||||
path,
|
||||
DefaultDataBufferFactory.sharedInstance,
|
||||
StreamUtils.BUFFER_SIZE)));
|
||||
|
||||
Supplier<Optional<Flux<DataBuffer>>> contentFromDownloadUrl = () -> request.getDownloadUrl()
|
||||
.map(downloadURL -> {
|
||||
try {
|
||||
var url = new URL(downloadURL);
|
||||
|
@ -121,23 +149,27 @@ public class MigrationEndpoint implements CustomEndpoint {
|
|||
// Should never happen
|
||||
return Flux.<DataBuffer>error(e);
|
||||
}
|
||||
})
|
||||
.orElseGet(Flux::empty);
|
||||
});
|
||||
|
||||
var uploadContent = request.getFile()
|
||||
.map(Part::content)
|
||||
.orElseGet(Flux::empty);
|
||||
Supplier<Optional<Flux<DataBuffer>>> contentFromUpload = () -> request.getFile()
|
||||
.map(Part::content);
|
||||
|
||||
var backupFileContent = request.getBackupName()
|
||||
Supplier<Optional<Flux<DataBuffer>>> contentFromBackupName = () -> request.getBackupName()
|
||||
.map(backupName -> client.get(Backup.class, backupName)
|
||||
.flatMap(migrationService::download)
|
||||
.flatMapMany(resource -> DataBufferUtils.read(resource,
|
||||
DefaultDataBufferFactory.sharedInstance,
|
||||
StreamUtils.BUFFER_SIZE)))
|
||||
.orElseGet(Flux::empty);
|
||||
return uploadContent
|
||||
.switchIfEmpty(downloadContent)
|
||||
.switchIfEmpty(backupFileContent);
|
||||
StreamUtils.BUFFER_SIZE)));
|
||||
|
||||
return Optionals.firstNonEmpty(
|
||||
contentFromUpload,
|
||||
contentFromDownloadUrl,
|
||||
contentFromBackupName,
|
||||
contentFromFilename
|
||||
)
|
||||
.orElseGet(() -> Flux.error(new ServerWebInputException("""
|
||||
Please upload a file or provide a download link or backup name or backup filename.\
|
||||
""")));
|
||||
}
|
||||
|
||||
@Schema(types = "object")
|
||||
|
@ -157,13 +189,26 @@ public class MigrationEndpoint implements CustomEndpoint {
|
|||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Schema(requiredMode = NOT_REQUIRED, name = "filename", description = """
|
||||
Filename of backup file in backups root.\
|
||||
""")
|
||||
public Optional<String> getFilename() {
|
||||
var part = multipart.getFirst("filename");
|
||||
if (part instanceof FormFieldPart filenamePart) {
|
||||
return Optional.of(filenamePart.value())
|
||||
.filter(StringUtils::hasText);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Schema(requiredMode = NOT_REQUIRED,
|
||||
name = "downloadUrl",
|
||||
description = "Remote backup HTTP URL.")
|
||||
public Optional<String> getDownloadUrl() {
|
||||
var part = multipart.getFirst("downloadUrl");
|
||||
if (part instanceof FormFieldPart downloadUrlPart) {
|
||||
return Optional.of(downloadUrlPart.value());
|
||||
return Optional.of(downloadUrlPart.value())
|
||||
.filter(StringUtils::hasText);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
@ -174,16 +219,16 @@ public class MigrationEndpoint implements CustomEndpoint {
|
|||
public Optional<String> getBackupName() {
|
||||
var part = multipart.getFirst("backupName");
|
||||
if (part instanceof FormFieldPart backupNamePart) {
|
||||
return Optional.of(backupNamePart.value());
|
||||
return Optional.of(backupNamePart.value())
|
||||
.filter(StringUtils::hasText);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion(
|
||||
"api.console." + Constant.GROUP + "/" + Constant.VERSION);
|
||||
"console.api." + Constant.GROUP + "/" + Constant.VERSION);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package run.halo.app.migration;
|
|||
import org.reactivestreams.Publisher;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface MigrationService {
|
||||
|
@ -21,4 +22,19 @@ public interface MigrationService {
|
|||
*/
|
||||
Mono<Void> cleanup(Backup backup);
|
||||
|
||||
/**
|
||||
* Gets backup files.
|
||||
*
|
||||
* @return backup files, sorted by last modified time.
|
||||
*/
|
||||
Flux<BackupFile> getBackupFiles();
|
||||
|
||||
/**
|
||||
* Get backup file by filename.
|
||||
*
|
||||
* @param filename filename of backup file
|
||||
* @return backup file or empty if file is not found
|
||||
*/
|
||||
Mono<BackupFile> getBackupFile(String filename);
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package run.halo.app.migration.impl;
|
||||
|
||||
import static java.nio.file.Files.deleteIfExists;
|
||||
import static java.util.Comparator.comparing;
|
||||
import static org.apache.commons.io.FilenameUtils.isExtension;
|
||||
import static org.springframework.util.FileSystemUtils.copyRecursively;
|
||||
import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal;
|
||||
import static run.halo.app.infra.utils.FileUtils.copyRecursively;
|
||||
|
@ -19,8 +21,10 @@ import java.time.ZoneId;
|
|||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.BaseStream;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
|
@ -40,11 +44,12 @@ import run.halo.app.infra.exception.NotFoundException;
|
|||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.infra.utils.FileUtils;
|
||||
import run.halo.app.migration.Backup;
|
||||
import run.halo.app.migration.BackupFile;
|
||||
import run.halo.app.migration.MigrationService;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MigrationServiceImpl implements MigrationService {
|
||||
public class MigrationServiceImpl implements MigrationService, InitializingBean {
|
||||
|
||||
private final ExtensionStoreRepository repository;
|
||||
|
||||
|
@ -163,6 +168,49 @@ public class MigrationServiceImpl implements MigrationService {
|
|||
}).subscribeOn(scheduler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<BackupFile> getBackupFiles() {
|
||||
return Flux.using(
|
||||
() -> Files.list(getBackupsRoot()),
|
||||
Flux::fromStream,
|
||||
BaseStream::close
|
||||
)
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(Files::isReadable)
|
||||
.filter(path -> isExtension(path.getFileName().toString(), "zip"))
|
||||
.map(this::toBackupFile)
|
||||
.sort(comparing(BackupFile::getLastModifiedTime).reversed()
|
||||
.thenComparing(BackupFile::getFilename)
|
||||
)
|
||||
.subscribeOn(this.scheduler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<BackupFile> getBackupFile(String filename) {
|
||||
return Mono.fromCallable(() -> {
|
||||
var backupsRoot = getBackupsRoot();
|
||||
var backupFilePath = backupsRoot.resolve(filename);
|
||||
checkDirectoryTraversal(backupsRoot, backupFilePath);
|
||||
if (Files.notExists(backupFilePath)) {
|
||||
return null;
|
||||
}
|
||||
return toBackupFile(backupFilePath);
|
||||
}).subscribeOn(this.scheduler);
|
||||
}
|
||||
|
||||
private BackupFile toBackupFile(Path path) {
|
||||
var backupFile = new BackupFile();
|
||||
backupFile.setPath(path);
|
||||
backupFile.setFilename(path.getFileName().toString());
|
||||
try {
|
||||
backupFile.setSize(Files.size(path));
|
||||
backupFile.setLastModifiedTime(Files.getLastModifiedTime(path).toInstant());
|
||||
return backupFile;
|
||||
} catch (IOException e) {
|
||||
throw Exceptions.propagate(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Mono<Void> restoreWorkdir(Path backupRoot) {
|
||||
return Mono.<Void>create(sink -> {
|
||||
try {
|
||||
|
@ -264,4 +312,9 @@ public class MigrationServiceImpl implements MigrationService {
|
|||
FileUtils::closeQuietly))
|
||||
.subscribeOn(scheduler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() throws Exception {
|
||||
Files.createDirectories(getBackupsRoot());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,9 +10,15 @@ metadata:
|
|||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["system:migrations:manage"]
|
||||
rules:
|
||||
- apiGroups: ["api.console.migration.halo.run"]
|
||||
resources: ["restorations"]
|
||||
verbs: ["create"]
|
||||
- apiGroups: ["migration.halo.run"]
|
||||
resources: ["backups"]
|
||||
verbs: ["list", "get", "create", "update", "delete"]
|
||||
- apiGroups: [ "console.api.migration.halo.run" ]
|
||||
resources: [ "restorations" ]
|
||||
verbs: [ "create" ]
|
||||
- apiGroups: [ "console.api.migration.halo.run" ]
|
||||
resources: [ "backup-files" ]
|
||||
verbs: [ "list" ]
|
||||
- apiGroups: [ "console.api.migration.halo.run" ]
|
||||
resources: [ "backups/files" ]
|
||||
verbs: [ "get" ]
|
||||
- apiGroups: [ "migration.halo.run" ]
|
||||
resources: [ "backups" ]
|
||||
verbs: [ "list", "get", "create", "update", "delete", "patch" ]
|
||||
|
|
|
@ -14,6 +14,8 @@ import java.net.URISyntaxException;
|
|||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
@ -198,6 +200,66 @@ class MigrationServiceImplTest {
|
|||
verify(backupRoot).get();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupFilesTest() throws Exception {
|
||||
var now = Instant.now();
|
||||
var backup1 = tempDir.resolve("backup1.zip");
|
||||
Files.writeString(backup1, "fake-content");
|
||||
Files.setLastModifiedTime(backup1, FileTime.from(now));
|
||||
|
||||
var backup2 = tempDir.resolve("backup2.zip");
|
||||
Files.writeString(backup2, "fake--content");
|
||||
Files.setLastModifiedTime(
|
||||
backup2,
|
||||
FileTime.from(now.plus(Duration.ofSeconds(1)))
|
||||
);
|
||||
|
||||
var backup3 = tempDir.resolve("backup3.not-a-zip");
|
||||
Files.writeString(backup3, "fake-content");
|
||||
Files.setLastModifiedTime(
|
||||
backup3,
|
||||
FileTime.from(now.plus(Duration.ofSeconds(2)))
|
||||
);
|
||||
when(backupRoot.get()).thenReturn(tempDir);
|
||||
|
||||
migrationService.afterPropertiesSet();
|
||||
migrationService.getBackupFiles()
|
||||
.as(StepVerifier::create)
|
||||
.assertNext(backupFile -> {
|
||||
assertEquals("backup2.zip", backupFile.getFilename());
|
||||
assertEquals(13, backupFile.getSize());
|
||||
assertEquals(now.plus(Duration.ofSeconds(1)), backupFile.getLastModifiedTime());
|
||||
})
|
||||
.assertNext(backupFile -> {
|
||||
assertEquals("backup1.zip", backupFile.getFilename());
|
||||
assertEquals(12, backupFile.getSize());
|
||||
assertEquals(now, backupFile.getLastModifiedTime());
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupFileTest() throws Exception {
|
||||
var now = Instant.now();
|
||||
Files.writeString(tempDir.resolve("backup.zip"), "fake-content");
|
||||
Files.setLastModifiedTime(tempDir.resolve("backup.zip"), FileTime.from(now));
|
||||
when(backupRoot.get()).thenReturn(tempDir);
|
||||
|
||||
migrationService.afterPropertiesSet();
|
||||
migrationService.getBackupFile("backup.zip")
|
||||
.as(StepVerifier::create)
|
||||
.assertNext(backupFile -> {
|
||||
assertEquals("backup.zip", backupFile.getFilename());
|
||||
assertEquals(12, backupFile.getSize());
|
||||
assertEquals(now, backupFile.getLastModifiedTime());
|
||||
})
|
||||
.verifyComplete();
|
||||
|
||||
migrationService.getBackupFile("backup-not-exist.zip")
|
||||
.as(StepVerifier::create)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
Backup createSucceededBackup(String name, String filename) {
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(name);
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Backup } from "@halo-dev/api-client";
|
||||
import { formatDatetime } from "@/utils/date";
|
||||
import type { BackupFile } from "@halo-dev/api-client";
|
||||
import { consoleApiClient } from "@halo-dev/api-client";
|
||||
import {
|
||||
Dialog,
|
||||
Toast,
|
||||
VAlert,
|
||||
VButton,
|
||||
VEntity,
|
||||
VEntityField,
|
||||
VLoading,
|
||||
VTabItem,
|
||||
|
@ -13,23 +15,15 @@ import {
|
|||
} from "@halo-dev/components";
|
||||
import { useMutation, useQuery } from "@tanstack/vue-query";
|
||||
import axios from "axios";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import BackupListItem from "../components/BackupListItem.vue";
|
||||
import { useBackupFetch } from "../composables/use-backup";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { data: backups } = useBackupFetch();
|
||||
|
||||
const normalBackups = computed(() => {
|
||||
return backups.value?.items.filter((item) => {
|
||||
return item.status?.phase === "SUCCEEDED";
|
||||
});
|
||||
});
|
||||
|
||||
const complete = ref(false);
|
||||
const showUploader = ref(false);
|
||||
const activeTabId = ref("local");
|
||||
const activeTabId = ref<"local" | "remote" | "backups">("local");
|
||||
|
||||
const onProcessCompleted = () => {
|
||||
Dialog.success({
|
||||
|
@ -67,14 +61,26 @@ const { isLoading: downloading, mutate: handleRemoteDownload } = useMutation({
|
|||
},
|
||||
});
|
||||
|
||||
function handleRestoreFromBackup(backup: Backup) {
|
||||
const { data: backupFiles } = useQuery({
|
||||
queryKey: ["backup-files", activeTabId],
|
||||
queryFn: async () => {
|
||||
const { data } = await consoleApiClient.migration.getBackupFiles();
|
||||
return data;
|
||||
},
|
||||
enabled: computed(() => activeTabId.value === "backups"),
|
||||
});
|
||||
|
||||
function handleRestoreFromBackup(backupFile: BackupFile) {
|
||||
Dialog.info({
|
||||
title: t("core.backup.operations.restore_by_backup.title"),
|
||||
description: t("core.backup.operations.restore_by_backup.description", {
|
||||
filename: backupFile.filename,
|
||||
}),
|
||||
confirmText: t("core.common.buttons.confirm"),
|
||||
showCancel: false,
|
||||
cancelText: t("core.common.buttons.cancel"),
|
||||
async onConfirm() {
|
||||
await consoleApiClient.migration.restoreBackup({
|
||||
backupName: backup.metadata.name,
|
||||
filename: backupFile.filename,
|
||||
});
|
||||
setTimeout(() => {
|
||||
onProcessCompleted();
|
||||
|
@ -172,17 +178,31 @@ useQuery({
|
|||
:label="$t('core.backup.restore.tabs.backup.label')"
|
||||
>
|
||||
<ul
|
||||
class="box-border h-full w-full divide-y divide-gray-100 overflow-hidden rounded-base"
|
||||
class="box-border h-full w-full divide-y divide-gray-100 overflow-hidden rounded-base border"
|
||||
role="list"
|
||||
>
|
||||
<li v-for="(backup, index) in normalBackups" :key="index">
|
||||
<BackupListItem :show-operations="false" :backup="backup">
|
||||
<li v-for="backupFile in backupFiles" :key="backupFile.filename">
|
||||
<VEntity>
|
||||
<template #start>
|
||||
<VEntityField
|
||||
:title="backupFile.filename"
|
||||
:description="prettyBytes(backupFile.size || 0)"
|
||||
>
|
||||
</VEntityField>
|
||||
</template>
|
||||
<template #end>
|
||||
<VEntityField v-permission="['system:themes:manage']">
|
||||
<VEntityField v-if="backupFile.lastModifiedTime">
|
||||
<template #description>
|
||||
<span class="truncate text-xs tabular-nums text-gray-500">
|
||||
{{ formatDatetime(backupFile.lastModifiedTime) }}
|
||||
</span>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<VEntityField v-permission="['system:migrations:manage']">
|
||||
<template #description>
|
||||
<VButton
|
||||
size="sm"
|
||||
@click="handleRestoreFromBackup(backup)"
|
||||
@click="handleRestoreFromBackup(backupFile)"
|
||||
>
|
||||
{{
|
||||
$t("core.backup.operations.restore_by_backup.button")
|
||||
|
@ -191,7 +211,7 @@ useQuery({
|
|||
</template>
|
||||
</VEntityField>
|
||||
</template>
|
||||
</BackupListItem>
|
||||
</VEntity>
|
||||
</li>
|
||||
</ul>
|
||||
</VTabItem>
|
||||
|
|
|
@ -93,6 +93,7 @@ models/auth-provider-list.ts
|
|||
models/auth-provider-spec.ts
|
||||
models/auth-provider.ts
|
||||
models/author.ts
|
||||
models/backup-file.ts
|
||||
models/backup-list.ts
|
||||
models/backup-spec.ts
|
||||
models/backup-status.ts
|
||||
|
|
|
@ -21,6 +21,8 @@ import globalAxios from 'axios';
|
|||
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '../common';
|
||||
// @ts-ignore
|
||||
import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
|
||||
// @ts-ignore
|
||||
import { BackupFile } from '../models';
|
||||
/**
|
||||
* MigrationV1alpha1ConsoleApi - axios parameter creator
|
||||
* @export
|
||||
|
@ -39,7 +41,7 @@ export const MigrationV1alpha1ConsoleApiAxiosParamCreator = function (configurat
|
|||
assertParamExists('downloadBackups', 'name', name)
|
||||
// verify required parameter 'filename' is not null or undefined
|
||||
assertParamExists('downloadBackups', 'filename', filename)
|
||||
const localVarPath = `/apis/api.console.migration.halo.run/v1alpha1/backups/{name}/files/{filename}`
|
||||
const localVarPath = `/apis/console.api.migration.halo.run/v1alpha1/backups/{name}/files/{filename}`
|
||||
.replace(`{${"name"}}`, encodeURIComponent(String(name)))
|
||||
.replace(`{${"filename"}}`, encodeURIComponent(String(filename)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
|
@ -63,6 +65,43 @@ export const MigrationV1alpha1ConsoleApiAxiosParamCreator = function (configurat
|
|||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Get backup files from backup root.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getBackupFiles: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/apis/console.api.migration.halo.run/v1alpha1/backup-files`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication basicAuth required
|
||||
// http basic authentication required
|
||||
setBasicAuthToObject(localVarRequestOptions, configuration)
|
||||
|
||||
// authentication bearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
@ -77,11 +116,12 @@ export const MigrationV1alpha1ConsoleApiAxiosParamCreator = function (configurat
|
|||
* @param {string} [backupName] Backup metadata name.
|
||||
* @param {string} [downloadUrl] Remote backup HTTP URL.
|
||||
* @param {File} [file]
|
||||
* @param {string} [filename] Filename of backup file in backups root.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
restoreBackup: async (backupName?: string, downloadUrl?: string, file?: File, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/apis/api.console.migration.halo.run/v1alpha1/restorations`;
|
||||
restoreBackup: async (backupName?: string, downloadUrl?: string, file?: File, filename?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/apis/console.api.migration.halo.run/v1alpha1/restorations`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
|
@ -115,6 +155,10 @@ export const MigrationV1alpha1ConsoleApiAxiosParamCreator = function (configurat
|
|||
localVarFormParams.append('file', file as any);
|
||||
}
|
||||
|
||||
if (filename !== undefined) {
|
||||
localVarFormParams.append('filename', filename as any);
|
||||
}
|
||||
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'multipart/form-data';
|
||||
|
||||
|
@ -151,16 +195,28 @@ export const MigrationV1alpha1ConsoleApiFp = function(configuration?: Configurat
|
|||
const localVarOperationServerBasePath = operationServerMap['MigrationV1alpha1ConsoleApi.downloadBackups']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Get backup files from backup root.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getBackupFiles(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BackupFile>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getBackupFiles(options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['MigrationV1alpha1ConsoleApi.getBackupFiles']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Restore backup by uploading file or providing download link or backup name.
|
||||
* @param {string} [backupName] Backup metadata name.
|
||||
* @param {string} [downloadUrl] Remote backup HTTP URL.
|
||||
* @param {File} [file]
|
||||
* @param {string} [filename] Filename of backup file in backups root.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async restoreBackup(backupName?: string, downloadUrl?: string, file?: File, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.restoreBackup(backupName, downloadUrl, file, options);
|
||||
async restoreBackup(backupName?: string, downloadUrl?: string, file?: File, filename?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.restoreBackup(backupName, downloadUrl, file, filename, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['MigrationV1alpha1ConsoleApi.restoreBackup']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
|
@ -184,6 +240,14 @@ export const MigrationV1alpha1ConsoleApiFactory = function (configuration?: Conf
|
|||
downloadBackups(requestParameters: MigrationV1alpha1ConsoleApiDownloadBackupsRequest, options?: RawAxiosRequestConfig): AxiosPromise<void> {
|
||||
return localVarFp.downloadBackups(requestParameters.name, requestParameters.filename, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Get backup files from backup root.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getBackupFiles(options?: RawAxiosRequestConfig): AxiosPromise<Array<BackupFile>> {
|
||||
return localVarFp.getBackupFiles(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Restore backup by uploading file or providing download link or backup name.
|
||||
* @param {MigrationV1alpha1ConsoleApiRestoreBackupRequest} requestParameters Request parameters.
|
||||
|
@ -191,7 +255,7 @@ export const MigrationV1alpha1ConsoleApiFactory = function (configuration?: Conf
|
|||
* @throws {RequiredError}
|
||||
*/
|
||||
restoreBackup(requestParameters: MigrationV1alpha1ConsoleApiRestoreBackupRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise<void> {
|
||||
return localVarFp.restoreBackup(requestParameters.backupName, requestParameters.downloadUrl, requestParameters.file, options).then((request) => request(axios, basePath));
|
||||
return localVarFp.restoreBackup(requestParameters.backupName, requestParameters.downloadUrl, requestParameters.file, requestParameters.filename, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -243,6 +307,13 @@ export interface MigrationV1alpha1ConsoleApiRestoreBackupRequest {
|
|||
* @memberof MigrationV1alpha1ConsoleApiRestoreBackup
|
||||
*/
|
||||
readonly file?: File
|
||||
|
||||
/**
|
||||
* Filename of backup file in backups root.
|
||||
* @type {string}
|
||||
* @memberof MigrationV1alpha1ConsoleApiRestoreBackup
|
||||
*/
|
||||
readonly filename?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -263,6 +334,16 @@ export class MigrationV1alpha1ConsoleApi extends BaseAPI {
|
|||
return MigrationV1alpha1ConsoleApiFp(this.configuration).downloadBackups(requestParameters.name, requestParameters.filename, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup files from backup root.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof MigrationV1alpha1ConsoleApi
|
||||
*/
|
||||
public getBackupFiles(options?: RawAxiosRequestConfig) {
|
||||
return MigrationV1alpha1ConsoleApiFp(this.configuration).getBackupFiles(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore backup by uploading file or providing download link or backup name.
|
||||
* @param {MigrationV1alpha1ConsoleApiRestoreBackupRequest} requestParameters Request parameters.
|
||||
|
@ -271,7 +352,7 @@ export class MigrationV1alpha1ConsoleApi extends BaseAPI {
|
|||
* @memberof MigrationV1alpha1ConsoleApi
|
||||
*/
|
||||
public restoreBackup(requestParameters: MigrationV1alpha1ConsoleApiRestoreBackupRequest = {}, options?: RawAxiosRequestConfig) {
|
||||
return MigrationV1alpha1ConsoleApiFp(this.configuration).restoreBackup(requestParameters.backupName, requestParameters.downloadUrl, requestParameters.file, options).then((request) => request(this.axios, this.basePath));
|
||||
return MigrationV1alpha1ConsoleApiFp(this.configuration).restoreBackup(requestParameters.backupName, requestParameters.downloadUrl, requestParameters.file, requestParameters.filename, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Halo
|
||||
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||
*
|
||||
* The version of the OpenAPI document: 2.19.0-SNAPSHOT
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface BackupFile
|
||||
*/
|
||||
export interface BackupFile {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof BackupFile
|
||||
*/
|
||||
'filename'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof BackupFile
|
||||
*/
|
||||
'lastModifiedTime'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof BackupFile
|
||||
*/
|
||||
'size'?: number;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ export * from './auth-provider-list';
|
|||
export * from './auth-provider-spec';
|
||||
export * from './author';
|
||||
export * from './backup';
|
||||
export * from './backup-file';
|
||||
export * from './backup-list';
|
||||
export * from './backup-spec';
|
||||
export * from './backup-status';
|
||||
|
|
|
@ -1252,8 +1252,12 @@ core:
|
|||
qrcode:
|
||||
label: "Use the validator application to scan the QR code below:"
|
||||
manual:
|
||||
label: "If you can't scan the QR code, click to view the alternative steps."
|
||||
help: "Manually configure the validator application with the following code:"
|
||||
label: >-
|
||||
If you can't scan the QR code, click to view the alternative
|
||||
steps.
|
||||
help: >-
|
||||
Manually configure the validator application with the
|
||||
following code:
|
||||
pat:
|
||||
operations:
|
||||
delete:
|
||||
|
@ -1460,7 +1464,10 @@ core:
|
|||
button: Download and restore
|
||||
restore_by_backup:
|
||||
button: Restore
|
||||
title: Restore from this backup
|
||||
title: Restore from backup file
|
||||
description: >-
|
||||
After clicking OK, data will be restored from the backup file
|
||||
{filename}.
|
||||
list:
|
||||
phases:
|
||||
pending: Pending
|
||||
|
@ -1490,7 +1497,7 @@ core:
|
|||
fields:
|
||||
url: Remote URL
|
||||
backup:
|
||||
label: Restore from backup
|
||||
label: Restore from backup files
|
||||
exception:
|
||||
not_found:
|
||||
message: Page not found
|
||||
|
|
|
@ -1362,7 +1362,8 @@ core:
|
|||
button: 下载并恢复
|
||||
restore_by_backup:
|
||||
button: 恢复
|
||||
title: 从此备份进行恢复
|
||||
title: 从备份文件恢复
|
||||
description: 点击确定后,将从备份文件 {filename} 恢复数据。
|
||||
list:
|
||||
phases:
|
||||
pending: 准备中
|
||||
|
@ -1386,7 +1387,7 @@ core:
|
|||
fields:
|
||||
url: 下载地址
|
||||
backup:
|
||||
label: 从备份恢复
|
||||
label: 从备份文件恢复
|
||||
exception:
|
||||
not_found:
|
||||
message: 没有找到该页面
|
||||
|
|
|
@ -1343,7 +1343,8 @@ core:
|
|||
button: 下載並還原
|
||||
restore_by_backup:
|
||||
button: 還原
|
||||
title: 確認要從此備份進行還原嗎?
|
||||
title: 從備份檔案恢復
|
||||
description: 點選確定後,將從備份檔案 {filename} 還原資料。
|
||||
list:
|
||||
phases:
|
||||
pending: 準備中
|
||||
|
@ -1367,7 +1368,7 @@ core:
|
|||
fields:
|
||||
url: 下載地址
|
||||
backup:
|
||||
label: 從備份還原
|
||||
label: 從備份檔案恢復
|
||||
exception:
|
||||
not_found:
|
||||
message: 沒有找到該頁面
|
||||
|
|
Loading…
Reference in New Issue