diff --git a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs index 2d810f524..3c3cfc36f 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs @@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Apps if (!(isReserved = await index.ReserveAppAsync(appCreated.AppId.Id, appCreated.AppId.Name))) { - throw new DomainException("The app id or name is not available."); + throw new BackupRestoreException("The app id or name is not available."); } break; @@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Apps } } - public override async Task RestoreAsync(Guid appId, BackupReader reader) + public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) { await grainFactory.GetGrain(SingleGrain.Id).AddAppAsync(appCreated.AppId.Id, appCreated.AppId.Name); diff --git a/src/Squidex.Domain.Apps.Entities/Backup/AppCleanerGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/AppCleanerGrain.cs index 14fe28fca..c833350c0 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/AppCleanerGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/AppCleanerGrain.cs @@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Backup private bool isCleaning; private State state = new State(); - [CollectionName("Index_AppsByName")] + [CollectionName("AppCleaner")] public sealed class State { public HashSet Apps { get; set; } = new HashSet(); @@ -61,35 +61,40 @@ namespace Squidex.Domain.Apps.Entities.Backup public async override Task OnActivateAsync(string key) { - await RegisterOrUpdateReminder("Default", TimeSpan.Zero, TimeSpan.FromMinutes(2)); + await RegisterOrUpdateReminder("Default", TimeSpan.Zero, TimeSpan.FromMinutes(1)); persistence = store.WithSnapshots(Guid.Empty, s => { state = s; }); - await persistence.ReadAsync(); + await ReadAsync(); - await CleanAsync(); + Clean(); } public Task EnqueueAppAsync(Guid appId) { - state.Apps.Add(appId); + if (appId != Guid.Empty) + { + state.Apps.Add(appId); + + Clean(); + } - return persistence.WriteSnapshotAsync(state); + return WriteAsync(); } public Task ActivateAsync() { - CleanAsync().Forget(); + Clean(); return TaskHelper.Done; } public Task ReceiveReminder(string reminderName, TickStatus status) { - CleanAsync().Forget(); + Clean(); return TaskHelper.Done; } @@ -100,14 +105,18 @@ namespace Squidex.Domain.Apps.Entities.Backup { return Task.FromResult(CleanerStatus.Cleaning); } - else if (state.FailedApps.Contains(appId)) + + if (state.FailedApps.Contains(appId)) { return Task.FromResult(CleanerStatus.Failed); } - else - { - return Task.FromResult(CleanerStatus.Cleaned); - } + + return Task.FromResult(CleanerStatus.Cleaned); + } + + private void Clean() + { + CleanAsync().Forget(); } private async Task CleanAsync() @@ -142,7 +151,7 @@ namespace Squidex.Domain.Apps.Entities.Backup .WriteProperty("status", "started") .WriteProperty("appId", appId.ToString())); - await CleanupCoreAsync(appId); + await CleanupAppCoreAsync(appId); log.LogInformation(w => { @@ -170,16 +179,16 @@ namespace Squidex.Domain.Apps.Entities.Backup { state.Apps.Remove(appId); - await persistence.WriteSnapshotAsync(state); + await WriteAsync(); } } } - private async Task CleanupCoreAsync(Guid appId) + private async Task CleanupAppCoreAsync(Guid appId) { using (Profiler.Trace("DeleteEvents")) { - await eventStore.DeleteManyAsync("AppId", appId); + await eventStore.DeleteManyAsync("AppId", appId.ToString()); } foreach (var handler in handlers) @@ -190,5 +199,15 @@ namespace Squidex.Domain.Apps.Entities.Backup } } } + + private async Task ReadAsync() + { + await persistence.ReadAsync(); + } + + private async Task WriteAsync() + { + await persistence.WriteSnapshotAsync(state); + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs index b6840a584..677961d26 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs @@ -115,7 +115,7 @@ namespace Squidex.Domain.Apps.Entities.Backup await CleanupArchiveAsync(job); await CleanupBackupAsync(job); - job.IsFailed = true; + job.Status = JobStatus.Failed; await WriteAsync(); } @@ -164,7 +164,12 @@ namespace Squidex.Domain.Apps.Entities.Backup throw new DomainException($"You cannot have more than {MaxBackups} backups."); } - var job = new BackupStateJob { Id = Guid.NewGuid(), Started = clock.GetCurrentInstant() }; + var job = new BackupStateJob + { + Id = Guid.NewGuid(), + Started = clock.GetCurrentInstant(), + Status = JobStatus.Started + }; currentTask = new CancellationTokenSource(); currentJob = job; @@ -195,14 +200,7 @@ namespace Squidex.Domain.Apps.Entities.Backup job.HandledEvents = writer.WrittenEvents; job.HandledAssets = writer.WrittenAttachments; - var now = clock.GetCurrentInstant(); - - if ((now - lastTimestamp) >= UpdateDuration) - { - lastTimestamp = now; - - await WriteAsync(); - } + lastTimestamp = await WritePeriodically(lastTimestamp); }, SquidexHeaders.AppId, appId.ToString(), null, currentTask.Token); foreach (var handler in handlers) @@ -230,7 +228,7 @@ namespace Squidex.Domain.Apps.Entities.Backup .WriteProperty("status", "failed") .WriteProperty("backupId", job.Id.ToString())); - job.IsFailed = true; + job.Status = JobStatus.Failed; } finally { @@ -245,6 +243,20 @@ namespace Squidex.Domain.Apps.Entities.Backup } } + private async Task WritePeriodically(Instant lastTimestamp) + { + var now = clock.GetCurrentInstant(); + + if (ShouldUpdate(lastTimestamp, now)) + { + lastTimestamp = now; + + await WriteAsync(); + } + + return lastTimestamp; + } + public async Task DeleteAsync(Guid id) { var job = state.Jobs.FirstOrDefault(x => x.Id == id); @@ -274,6 +286,11 @@ namespace Squidex.Domain.Apps.Entities.Backup return J.AsTask(state.Jobs.OfType().ToList()); } + private static bool ShouldUpdate(Instant lastTimestamp, Instant now) + { + return (now - lastTimestamp) >= UpdateDuration; + } + private bool IsRunning() { return state.Jobs.Any(x => !x.Stopped.HasValue); diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs index b3df4d5d1..0cf558335 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs @@ -14,6 +14,8 @@ namespace Squidex.Domain.Apps.Entities.Backup { public abstract class BackupHandler { + public abstract string Name { get; } + public virtual Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader) { return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs index 2de75b269..731cc92e2 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs @@ -10,6 +10,7 @@ using System.IO; using System.IO.Compression; using System.Threading.Tasks; using Newtonsoft.Json; +using Squidex.Domain.Apps.Entities.Backup.Archive; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; @@ -17,8 +18,6 @@ namespace Squidex.Domain.Apps.Entities.Backup { public sealed class BackupReader : DisposableObjectBase { - private const int MaxEventsPerFolder = 1000; - private const int MaxAttachmentFolders = 1000; private static readonly JsonSerializer JsonSerializer = JsonSerializer.CreateDefault(); private readonly ZipArchive archive; private int readEvents; @@ -52,9 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Backup Guard.NotNullOrEmpty(name, nameof(name)); Guard.NotNull(handler, nameof(handler)); - var attachmentFolder = Math.Abs(name.GetHashCode() % MaxAttachmentFolders); - var attachmentPath = $"attachments/{attachmentFolder}/{name}"; - var attachmentEntry = archive.GetEntry(attachmentPath); + var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); if (attachmentEntry == null) { @@ -75,9 +72,7 @@ namespace Squidex.Domain.Apps.Entities.Backup while (true) { - var eventFolder = readEvents / MaxEventsPerFolder; - var eventPath = $"events/{eventFolder}/{readEvents}.json"; - var eventEntry = archive.GetEntry(eventPath); + var eventEntry = archive.GetEntry(ArchiveHelper.GetEventPath(readEvents)); if (eventEntry == null) { diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.cs new file mode 100644 index 000000000..f7fec5453 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Runtime.Serialization; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + [Serializable] + public class BackupRestoreException : Exception + { + public BackupRestoreException(string message) + : base(message) + { + } + + public BackupRestoreException(string message, Exception inner) + : base(message, inner) + { + } + + protected BackupRestoreException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs index d8951d403..5f424036c 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs @@ -10,6 +10,7 @@ using System.IO; using System.IO.Compression; using System.Threading.Tasks; using Newtonsoft.Json; +using Squidex.Domain.Apps.Entities.Backup.Archive; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; @@ -17,8 +18,6 @@ namespace Squidex.Domain.Apps.Entities.Backup { public sealed class BackupWriter : DisposableObjectBase { - private const int MaxEventsPerFolder = 1000; - private const int MaxAttachmentFolders = 1000; private static readonly JsonSerializer JsonSerializer = JsonSerializer.CreateDefault(); private readonly ZipArchive archive; private int writtenEvents; @@ -52,9 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Backup Guard.NotNullOrEmpty(name, nameof(name)); Guard.NotNull(handler, nameof(handler)); - var attachmentFolder = Math.Abs(name.GetHashCode() % MaxAttachmentFolders); - var attachmentPath = $"attachments/{attachmentFolder}/{name}"; - var attachmentEntry = archive.CreateEntry(attachmentPath); + var attachmentEntry = archive.CreateEntry(ArchiveHelper.GetAttachmentPath(name)); using (var stream = attachmentEntry.Open()) { @@ -66,9 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Backup public void WriteEvent(StoredEvent storedEvent) { - var eventFolder = writtenEvents / MaxEventsPerFolder; - var eventPath = $"events/{eventFolder}/{writtenEvents}.json"; - var eventEntry = archive.GetEntry(eventPath) ?? archive.CreateEntry(eventPath); + var eventEntry = archive.CreateEntry(ArchiveHelper.GetEventPath(writtenEvents)); using (var stream = eventEntry.Open()) { diff --git a/src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs new file mode 100644 index 000000000..ed0c5778c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Backup.Archive +{ + public static class ArchiveHelper + { + private const int MaxAttachmentFolders = 1000; + private const int MaxEventsPerFolder = 1000; + + public static string GetAttachmentPath(string name) + { + name = name.ToLowerInvariant(); + + var attachmentFolder = SimpleHash(name) % MaxAttachmentFolders; + var attachmentPath = $"attachments/{attachmentFolder}/{name}"; + + return attachmentPath; + } + + public static string GetEventPath(int index) + { + var eventFolder = index / MaxEventsPerFolder; + var eventPath = $"events/{eventFolder}/{index}.json"; + + return eventPath; + } + + private static int SimpleHash(string value) + { + var hash = 17; + + foreach (char c in value) + { + unchecked + { + hash = (hash * 23) + c.GetHashCode(); + } + } + + return Math.Abs(hash); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs new file mode 100644 index 000000000..f002efa81 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs @@ -0,0 +1,66 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup.Helpers +{ + public static class Downloader + { + public static async Task DownloadAsync(this IBackupArchiveLocation backupArchiveLocation, Uri url, Guid id) + { + HttpResponseMessage response = null; + try + { + using (var client = new HttpClient()) + { + response = await client.GetAsync(url); + response.EnsureSuccessStatusCode(); + + using (var sourceStream = await response.Content.ReadAsStreamAsync()) + { + using (var targetStream = await backupArchiveLocation.OpenStreamAsync(id)) + { + await sourceStream.CopyToAsync(targetStream); + } + } + } + } + catch (HttpRequestException ex) + { + throw new BackupRestoreException($"Cannot download the archive. Got status code: {response?.StatusCode}.", ex); + } + } + + public static async Task OpenArchiveAsync(this IBackupArchiveLocation backupArchiveLocation, Guid id) + { + Stream stream = null; + + try + { + stream = await backupArchiveLocation.OpenStreamAsync(id); + + return new BackupReader(stream); + } + catch (IOException) + { + stream?.Dispose(); + + throw new BackupRestoreException("The backup archive is correupt and cannot be opened."); + } + catch (Exception) + { + stream?.Dispose(); + + throw; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs index 4142fd5e0..56fe0a19e 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs @@ -22,6 +22,6 @@ namespace Squidex.Domain.Apps.Entities.Backup int HandledAssets { get; } - bool IsFailed { get; } + JobStatus Status { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs index 219169dd3..f38df5629 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs @@ -7,15 +7,15 @@ using System; using System.Threading.Tasks; -using Squidex.Infrastructure; +using Orleans; using Squidex.Infrastructure.Orleans; namespace Squidex.Domain.Apps.Entities.Backup { - public interface IRestoreGrain + public interface IRestoreGrain : IGrainWithStringKey { - Task RestoreAsync(Uri url, RefToken user); + Task RestoreAsync(Uri url); - Task> GetStateAsync(); + Task> GetJobAsync(); } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs index b3714d15d..8d0db185c 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using NodaTime; namespace Squidex.Domain.Apps.Entities.Backup @@ -16,8 +17,10 @@ namespace Squidex.Domain.Apps.Entities.Backup Instant Started { get; } - bool IsFailed { get; } + Instant? Stopped { get; } - string Status { get; } + List Log { get; } + + JobStatus Status { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs b/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs new file mode 100644 index 000000000..26f6f541c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public enum JobStatus + { + Created, + Started, + Completed, + Failed + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs index 2be9eef36..fe50bca91 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs @@ -7,10 +7,10 @@ using System; using System.Collections.Generic; -using System.Net.Http; using System.Threading.Tasks; using NodaTime; using Orleans; +using Squidex.Domain.Apps.Entities.Backup.Helpers; using Squidex.Domain.Apps.Entities.Backup.State; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Apps; @@ -26,7 +26,6 @@ namespace Squidex.Domain.Apps.Entities.Backup { public sealed class RestoreGrain : GrainOfString, IRestoreGrain { - private static readonly Duration UpdateDuration = Duration.FromSeconds(1); private readonly IClock clock; private readonly IAssetStore assetStore; private readonly IEventDataFormatter eventDataFormatter; @@ -37,6 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Backup private readonly IBackupArchiveLocation backupArchiveLocation; private readonly IStore store; private readonly IEnumerable handlers; + private RefToken actor; private RestoreState state = new RestoreState(); private IPersistence persistence; @@ -76,36 +76,56 @@ namespace Squidex.Domain.Apps.Entities.Backup public override async Task OnActivateAsync(string key) { + actor = new RefToken("subject", key); + persistence = store.WithSnapshots(GetType(), key, s => state = s); - await persistence.ReadAsync(); + await ReadAsync(); - await CleanupAsync(); + RecoverAfterRestart(); } - public Task RestoreAsync(Uri url, RefToken user) + private void RecoverAfterRestart() { - if (state.Job != null) - { - throw new DomainException("A restore operation is already running."); - } + RecoverAfterRestartAsync().Forget(); + } - state.Job = new RestoreStateJob { Started = clock.GetCurrentInstant(), Uri = url, User = user }; + private async Task RecoverAfterRestartAsync() + { + if (state.Job?.Status == JobStatus.Started) + { + Log("Failed due application restart"); - return ProcessAsync(); + await CleanupAsync(); + await WriteAsync(); + } } - private async Task CleanupAsync() + public Task RestoreAsync(Uri url) { - if (state.Job != null) + Guard.NotNull(url, nameof(url)); + + if (state.Job?.Status == JobStatus.Started) + { + throw new DomainException("A restore operation is already running."); + } + + state.Job = new RestoreStateJob { - state.Job.Status = "Failed due application restart"; - state.Job.IsFailed = true; + Id = Guid.NewGuid(), + Started = clock.GetCurrentInstant(), + Status = JobStatus.Started, + Uri = url + }; - TryCleanup(); + Process(); - await persistence.WriteSnapshotAsync(state); - } + return TaskHelper.Done; + } + + private void Process() + { + ProcessAsync().Forget(); } private async Task ProcessAsync() @@ -119,49 +139,40 @@ namespace Squidex.Domain.Apps.Entities.Backup .WriteProperty("status", "started") .WriteProperty("url", state.Job.Uri.ToString())); - state.Job.Status = "Downloading Backup"; - using (Profiler.Trace("Download")) { await DownloadAsync(); } - state.Job.Status = "Downloaded Backup"; - - using (var stream = await backupArchiveLocation.OpenStreamAsync(state.Job.Id)) + using (var reader = await backupArchiveLocation.OpenArchiveAsync(state.Job.Id)) { - using (var reader = new BackupReader(stream)) + using (Profiler.Trace("ReadEvents")) + { + await ReadEventsAsync(reader); + } + + foreach (var handler in handlers) { - using (Profiler.Trace("ReadEvents")) + using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.RestoreAsync))) { - await ReadEventsAsync(reader); + await handler.RestoreAsync(state.Job.AppId, reader); } - state.Job.Status = "Events read"; + Log($"Restored {handler.Name}"); + } - foreach (var handler in handlers) + foreach (var handler in handlers) + { + using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.CompleteRestoreAsync))) { - using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.RestoreAsync))) - { - await handler.RestoreAsync(state.Job.AppId, reader); - } - - state.Job.Status = $"{handler} Processed"; + await handler.CompleteRestoreAsync(state.Job.AppId, reader); } - foreach (var handler in handlers) - { - using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.CompleteRestoreAsync))) - { - await handler.CompleteRestoreAsync(state.Job.AppId, reader); - } - - state.Job.Status = $"{handler} Completed"; - } + Log($"Completed {handler.Name}"); } } - state.Job = null; + state.Job.Status = JobStatus.Failed; log.LogInformation(w => { @@ -174,17 +185,25 @@ namespace Squidex.Domain.Apps.Entities.Backup } catch (Exception ex) { - state.Job.IsFailed = true; + if (ex is BackupRestoreException backupException) + { + Log(backupException.Message); + } + else + { + Log("Failed with internal error"); + } - if (state.Job.AppId != Guid.Empty) + try { - foreach (var handler in handlers) - { - await handler.CleanupRestoreAsync(state.Job.AppId, ex); - } + await CleanupAsync(ex); + } + catch (Exception ex2) + { + ex = ex2; } - TryCleanup(); + state.Job.Status = JobStatus.Failed; log.LogError(ex, w => { @@ -197,37 +216,46 @@ namespace Squidex.Domain.Apps.Entities.Backup } finally { - await persistence.WriteSnapshotAsync(state); + await WriteAsync(); } } } - private async Task DownloadAsync() + private async Task CleanupAsync(Exception exception = null) { - using (var client = new HttpClient()) + await backupArchiveLocation.DeleteArchiveAsync(state.Job.Id); + + if (state.Job.AppId != Guid.Empty) { - using (var sourceStream = await client.GetStreamAsync(state.Job.Uri.ToString())) + foreach (var handler in handlers) { - using (var targetStream = await backupArchiveLocation.OpenStreamAsync(state.Job.Id)) - { - await sourceStream.CopyToAsync(targetStream); - } + await handler.CleanupRestoreAsync(state.Job.AppId, exception); } + + await appCleaner.EnqueueAppAsync(state.Job.AppId); } } + private async Task DownloadAsync() + { + Log("Downloading Backup"); + + await backupArchiveLocation.DownloadAsync(state.Job.Uri, state.Job.Id); + + Log("Downloaded Backup"); + } + private async Task ReadEventsAsync(BackupReader reader) { await reader.ReadEventsAsync(async (storedEvent) => { - var eventData = storedEvent.Data; - var eventParsed = eventDataFormatter.Parse(eventData); + var @event = eventDataFormatter.Parse(storedEvent.Data); - if (eventParsed.Payload is SquidexEvent squidexEvent) + if (@event.Payload is SquidexEvent squidexEvent) { - squidexEvent.Actor = state.Job.User; + squidexEvent.Actor = actor; } - else if (eventParsed.Payload is AppCreated appCreated) + else if (@event.Payload is AppCreated appCreated) { state.Job.AppId = appCreated.AppId.Id; @@ -236,13 +264,15 @@ namespace Squidex.Domain.Apps.Entities.Backup foreach (var handler in handlers) { - await handler.RestoreEventAsync(eventParsed, state.Job.AppId, reader); + await handler.RestoreEventAsync(@event, state.Job.AppId, reader); } await eventStore.AppendAsync(Guid.NewGuid(), storedEvent.StreamName, new List { storedEvent.Data }); - state.Job.Status = $"Handled event {reader.ReadEvents} events and {reader.ReadAttachments} attachments"; + Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments."); }); + + Log("Reading events completed."); } private async Task CheckCleanupStatus() @@ -253,24 +283,31 @@ namespace Squidex.Domain.Apps.Entities.Backup if (status == CleanerStatus.Cleaning) { - throw new DomainException("The app is removed in the background."); + throw new BackupRestoreException("The app is removed in the background."); } if (status == CleanerStatus.Cleaning) { - throw new DomainException("The app could not be cleaned."); + throw new BackupRestoreException("The app could not be cleaned."); } } - private void TryCleanup() + private void Log(string message) { - if (state.Job.AppId != Guid.Empty) - { - appCleaner.EnqueueAppAsync(state.Job.AppId).Forget(); - } + state.Job.Log.Add($"{clock.GetCurrentInstant()}: {message}"); + } + + private async Task ReadAsync() + { + await persistence.ReadAsync(); + } + + private async Task WriteAsync() + { + await persistence.WriteSnapshotAsync(state); } - public Task> GetStateAsync() + public Task> GetJobAsync() { return Task.FromResult>(state.Job); } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs index 58c5d37b4..6da5e9dfa 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs @@ -29,6 +29,6 @@ namespace Squidex.Domain.Apps.Entities.Backup.State public int HandledAssets { get; set; } [JsonProperty] - public bool IsFailed { get; set; } + public JobStatus Status { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs index 9c794bae3..1eac3612b 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs @@ -6,23 +6,20 @@ // ========================================================================== using System; +using System.Collections.Generic; using Newtonsoft.Json; using NodaTime; -using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Backup.State { public sealed class RestoreStateJob : IRestoreJob { [JsonProperty] - public Guid Id { get; set; } = Guid.NewGuid(); + public Guid Id { get; set; } [JsonProperty] public Guid AppId { get; set; } - [JsonProperty] - public RefToken User { get; set; } - [JsonProperty] public Uri Uri { get; set; } @@ -30,9 +27,12 @@ namespace Squidex.Domain.Apps.Entities.Backup.State public Instant Started { get; set; } [JsonProperty] - public string Status { get; set; } + public Instant? Stopped { get; set; } + + [JsonProperty] + public List Log { get; set; } [JsonProperty] - public bool IsFailed { get; set; } + public JobStatus Status { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs b/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs index b710a46ac..7fb773874 100644 --- a/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs +++ b/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs @@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Backup { var tempFile = GetTempFile(backupId); - return Task.FromResult(new FileStream(tempFile, FileMode.Create, FileAccess.ReadWrite)); + return Task.FromResult(new FileStream(tempFile, FileMode.OpenOrCreate, FileAccess.ReadWrite)); } public Task DeleteArchiveAsync(Guid backupId) @@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Backup private static string GetTempFile(Guid backupId) { - return Path.Combine(Path.GetTempPath(), backupId.ToString()); + return Path.Combine(Path.GetTempPath(), backupId.ToString() + ".zip"); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs index f4d837206..1e9b76654 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs @@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Tags { state.Tags = tags; - return persistence.DeleteAsync(); + return persistence.WriteSnapshotAsync(state); } public async Task> NormalizeTagsAsync(HashSet names, HashSet ids) diff --git a/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs b/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs index 8d5f8a548..416808ba0 100644 --- a/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs +++ b/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs @@ -6,14 +6,30 @@ // ========================================================================== using System; +using System.Threading; using System.Threading.Tasks; namespace Squidex.Infrastructure.Tasks { public static class TaskExtensions { + private static readonly Action IgnoreTaskContinuation = t => { var ignored = t.Exception; }; + public static void Forget(this Task task) { + if (task.IsCompleted) + { + var ignored = task.Exception; + } + else + { + task.ContinueWith( + IgnoreTaskContinuation, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } } public static Func ToDefault(this Action action) diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs new file mode 100644 index 000000000..a419a5f33 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using NodaTime; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class RestoreJobDto + { + /// + /// The uri to load from. + /// + [Required] + public Uri Uri { get; set; } + + /// + /// The status text. + /// + [Required] + public string Status { get; set; } + + /// + /// Indicates when the restore operation has been started. + /// + public Instant Started { get; set; } + + /// + /// Indicates if the restore has failed. + /// + public bool IsFailed { get; set; } + + public static RestoreJobDto FromJob(IRestoreJob job) + { + return SimpleMapper.Map(job, new RestoreJobDto()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs new file mode 100644 index 000000000..50dd8ec55 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class RestoreRequest + { + /// + /// The url to the restore file. + /// + [Required] + public Uri Url { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs new file mode 100644 index 000000000..8c133ff79 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -0,0 +1,86 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; +using Orleans; +using Squidex.Areas.Api.Controllers.Backups.Models; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; +using Squidex.Infrastructure.Tasks; +using Squidex.Pipeline; + +namespace Squidex.Areas.Api.Controllers.Backups +{ + /// + /// Restores backups. + /// + [ApiAuthorize] + [ApiExceptionFilter] + [ApiModelValidation(true)] + [SwaggerTag(nameof(Backups))] + public class RestoreController : ApiController + { + private readonly IGrainFactory grainFactory; + + public RestoreController(ICommandBus commandBus, IGrainFactory grainFactory) + : base(commandBus) + { + this.grainFactory = grainFactory; + } + + /// + /// Get the restore jobs. + /// + /// + /// 200 => Restore job returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/restore/")] + [ProducesResponseType(typeof(RestoreJobDto), 200)] + [ApiCosts(0)] + public async Task GetJob() + { + var restoreGrain = grainFactory.GetGrain(User.OpenIdSubject()); + + var job = await restoreGrain.GetJobAsync(); + + if (job.Value == null) + { + return NotFound(); + } + + var jobs = await restoreGrain.GetJobAsync(); + + var response = RestoreJobDto.FromJob(job.Value); + + return Ok(response); + } + + /// + /// Start a new restore job. + /// + /// The request object. + /// + /// 204 => Backup started. + /// + [HttpPost] + [Route("apps/restore/")] + [ApiCosts(0)] + public async Task PostRestore([FromBody] RestoreRequest request) + { + var restoreGrain = grainFactory.GetGrain(User.OpenIdSubject()); + + await restoreGrain.RestoreAsync(request.Url); + + return NoContent(); + } + } +} diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index b690a4d1b..385fb4e4e 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -185,12 +185,23 @@ namespace Squidex.Config.Domain private static void AddBackupHandlers(this IServiceCollection services) { - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddTransientAs() + .As(); + + services.AddTransientAs() + .As(); + + services.AddTransientAs() + .As(); + + services.AddTransientAs() + .As(); + + services.AddTransientAs() + .As(); + + services.AddTransientAs() + .As(); } public static void AddMyMigrationServices(this IServiceCollection services) @@ -198,6 +209,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsSelf(); + services.AddTransientAs() + .AsSelf(); + services.AddTransientAs() .As(); @@ -227,9 +241,6 @@ namespace Squidex.Config.Domain services.AddTransientAs() .As(); - - services.AddTransientAs() - .AsSelf(); } } } diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.html b/src/Squidex/app/features/apps/pages/apps-page.component.html index 461321025..1d29c5506 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.html +++ b/src/Squidex/app/features/apps/pages/apps-page.component.html @@ -1,6 +1,6 @@  -
+

Hi {{authState.user?.displayName}}

@@ -8,71 +8,115 @@
- -
-
-

You are not collaborating to any app yet

-
- -
-
-

{{app.name}}

- -
- Edit +
+
+
+ +
+
+

You are not collaborating to any app yet

+
+ +
+
+

{{app.name}}

+ +
+ Edit +
+
+
-
-
-
- - -
-
-
-
- -
- -

New App

- -
- Create a new blank app without content and schemas. -
-
-
- -
-
-
- -
- -

New Blog Sample

- -
-
Start with our ready to use blog.
-
- Sample Code: C# + + +
+
+
+
+ +
+ +

New App

+ +
+ Create a new blank app without content and schemas. +
+
+
+ +
+
+
+ +
+ +

New Blog Sample

+ +
+
Start with our ready to use blog.
+
+ Sample Code: C# +
+
+
+
+ +
+
+
+ +
+ +

New Profile Sample

+ +
+
Create your profile page.
+
+ Sample Code: C# +
+
+
- -
-
-
- +
+

Restore Backup

+ + +
+
+
+
+
+ +
+
+ +
+
+ +
+

Restore

+
+
+
+
+ Status: {{job.status}} +
+
-

New Profile Sample

- -
-
Create your profile page.
-
- Sample Code: C# +
+
+
+ +
+
+
-
+
diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.scss b/src/Squidex/app/features/apps/pages/apps-page.component.scss index b789d434d..835a60c5a 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.scss +++ b/src/Squidex/app/features/apps/pages/apps-page.component.scss @@ -12,11 +12,22 @@ padding-top: 2rem; padding-right: 1.25rem; padding-bottom: 0; - padding-left: $size-sidebar-width + .25rem; display: block; } } +.col-left { + padding-left: $size-sidebar-width + .25rem; +} + +.col-right { + border-left: 1px solid darken($color-border, 3%); + min-height: 200px; + min-width: 300px; + max-width: 300px; + padding: 0 2rem 0 1rem; +} + .card { & { margin-right: 1rem; @@ -79,4 +90,37 @@ text-decoration: none; } } +} + +$cicle-size: 1.5rem; + +.restore { + &-card { + float: none; + } + + &-status { + & { + @include circle($cicle-size); + line-height: $cicle-size + .1rem; + text-align: center; + font-size: .6 * $cicle-size; + font-weight: normal; + background: $color-border; + color: $color-dark-foreground; + vertical-align: middle; + } + + &-pending { + color: inherit; + } + + &-failed { + background: $color-theme-error; + } + + &-success { + background: $color-theme-green; + } + } } \ No newline at end of file diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.ts b/src/Squidex/app/features/apps/pages/apps-page.component.ts index c081ccb84..1414375b7 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.ts +++ b/src/Squidex/app/features/apps/pages/apps-page.component.ts @@ -5,15 +5,21 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, OnInit } from '@angular/core'; -import { take } from 'rxjs/operators'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { Subscription, timer } from 'rxjs'; +import { switchMap, take } from 'rxjs/operators'; import { AppsState, AuthService, + BackupsService, DialogModel, + DialogService, ModalModel, - OnboardingService + OnboardingService, + RestoreDto, + RestoreForm } from '@app/shared'; @Component({ @@ -21,20 +27,38 @@ import { styleUrls: ['./apps-page.component.scss'], templateUrl: './apps-page.component.html' }) -export class AppsPageComponent implements OnInit { +export class AppsPageComponent implements OnDestroy, OnInit { + private timerSubscription: Subscription; + public addAppDialog = new DialogModel(); public addAppTemplate = ''; + public restoreJob: RestoreDto | null; + public restoreForm = new RestoreForm(this.formBuilder); + public onboardingModal = new ModalModel(); constructor( public readonly appsState: AppsState, public readonly authState: AuthService, + private readonly backupsService: BackupsService, + private readonly dialogs: DialogService, + private readonly formBuilder: FormBuilder, private readonly onboardingService: OnboardingService ) { } + public ngOnDestroy() { + this.timerSubscription.unsubscribe(); + } + public ngOnInit() { + this.timerSubscription = + timer(0, 3000).pipe(switchMap(t => this.backupsService.getRestore())) + .subscribe(dto => { + this.restoreJob = dto; + }); + this.appsState.apps.pipe( take(1)) .subscribe(apps => { @@ -45,6 +69,21 @@ export class AppsPageComponent implements OnInit { }); } + public restore() { + const value = this.restoreForm.submit(); + + if (value) { + this.restoreForm.submitCompleted({}); + + this.backupsService.postRestore(value.url) + .subscribe(() => { + this.dialogs.notifyInfo('Restore started, it can take several minutes to complete.'); + }, error => { + this.dialogs.notifyError(error); + }); + } + } + public createNewApp(template: string) { this.addAppTemplate = template; this.addAppDialog.show(); diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html index d413f2ba4..17f3bf72b 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html @@ -33,7 +33,7 @@
-
+
diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss b/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss index 80a10e112..7d14e7dd0 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.scss @@ -1,14 +1,14 @@ @import '_vars'; @import '_mixins'; -$cicle-size: 2.8rem; +$circle-size: 2.8rem; .backup-status { & { - @include circle($cicle-size); - line-height: $cicle-size + .1rem; + @include circle($circle-size); + line-height: $circle-size + .1rem; text-align: center; - font-size: .4 * $cicle-size; + font-size: .4 * $circle-size; font-weight: normal; background: $color-border; color: $color-dark-foreground; diff --git a/src/Squidex/app/features/settings/pages/languages/language.component.html b/src/Squidex/app/features/settings/pages/languages/language.component.html index fb6a2f7a8..7567144f8 100644 --- a/src/Squidex/app/features/settings/pages/languages/language.component.html +++ b/src/Squidex/app/features/settings/pages/languages/language.component.html @@ -40,7 +40,7 @@
-
+
{{language.englishName}}
diff --git a/src/Squidex/app/shared/components/history-list.component.scss b/src/Squidex/app/shared/components/history-list.component.scss index 0eabbf8e9..17c4744e7 100644 --- a/src/Squidex/app/shared/components/history-list.component.scss +++ b/src/Squidex/app/shared/components/history-list.component.scss @@ -15,6 +15,10 @@ margin-top: .25rem; } +.user-ref { + padding-right: .25rem; +} + .event { & { color: $color-history; diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts index 02a02f1fa..51fb48eb8 100644 --- a/src/Squidex/app/shared/internal.ts +++ b/src/Squidex/app/shared/internal.ts @@ -44,6 +44,7 @@ export * from './state/apps.forms'; export * from './state/apps.state'; export * from './state/assets.forms'; export * from './state/assets.state'; +export * from './state/backups.forms'; export * from './state/backups.state'; export * from './state/clients.forms'; export * from './state/clients.state'; diff --git a/src/Squidex/app/shared/services/backups.service.ts b/src/Squidex/app/shared/services/backups.service.ts index d29099d8f..563b809e7 100644 --- a/src/Squidex/app/shared/services/backups.service.ts +++ b/src/Squidex/app/shared/services/backups.service.ts @@ -5,17 +5,18 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; import { AnalyticsService, ApiUrlConfig, DateTime, Model, - pretifyError + pretifyError, + Types } from '@app/framework'; export class BackupDto extends Model { @@ -35,6 +36,16 @@ export class BackupDto extends Model { } } +export class RestoreDto { + constructor( + public readonly started: DateTime, + public readonly status: string, + public readonly url: string, + public readonly isFailed: boolean + ) { + } +} + @Injectable() export class BackupsService { constructor( @@ -64,6 +75,29 @@ export class BackupsService { pretifyError('Failed to load backups.')); } + public getRestore(): Observable { + const url = this.apiUrl.buildUrl(`api/apps/restore`); + + return this.http.get(url).pipe( + map(response => { + const body: any = response; + + return new RestoreDto( + DateTime.parseISO_UTC(body.started), + body.status, + body.url, + body.isFailed); + }), + catchError(error => { + if (Types.is(error, HttpErrorResponse) && error.status === 404) { + return of(null); + } else { + return throwError(error); + } + }), + pretifyError('Failed to load backups.')); + } + public postBackup(appName: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups`); @@ -74,6 +108,16 @@ export class BackupsService { pretifyError('Failed to start backup.')); } + public postRestore(downloadUrl: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/restore`); + + return this.http.post(url, { url: downloadUrl }).pipe( + tap(() => { + this.analytics.trackEvent('Restore', 'Started'); + }), + pretifyError('Failed to start restore.')); + } + public deleteBackup(appName: string, id: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups/${id}`); diff --git a/src/Squidex/app/shared/state/backups.forms.ts b/src/Squidex/app/shared/state/backups.forms.ts new file mode 100644 index 000000000..cbdb82d5b --- /dev/null +++ b/src/Squidex/app/shared/state/backups.forms.ts @@ -0,0 +1,26 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { map, startWith } from 'rxjs/operators'; + +import { Form } from '@app/framework'; + +export class RestoreForm extends Form { + public hasNoUrl = + this.form.controls['url'].valueChanges.pipe(startWith(null), map(x => !x)); + + constructor(formBuilder: FormBuilder) { + super(formBuilder.group({ + url: [null, + [ + Validators.required + ] + ] + })); + } +} \ No newline at end of file