Browse Source

A lot of refactorings, does not build.

pull/311/head
Sebastian 8 years ago
parent
commit
7f4115873b
  1. 4
      src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs
  2. 53
      src/Squidex.Domain.Apps.Entities/Backup/AppCleanerGrain.cs
  3. 39
      src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs
  4. 2
      src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs
  5. 11
      src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs
  6. 31
      src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.cs
  7. 11
      src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs
  8. 50
      src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs
  9. 66
      src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs
  10. 2
      src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs
  11. 8
      src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs
  12. 7
      src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs
  13. 17
      src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs
  14. 185
      src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs
  15. 2
      src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs
  16. 14
      src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs
  17. 4
      src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs
  18. 2
      src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs
  19. 16
      src/Squidex.Infrastructure/Tasks/TaskExtensions.cs
  20. 45
      src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs
  21. 21
      src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs
  22. 86
      src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs
  23. 29
      src/Squidex/Config/Domain/EntitiesServices.cs
  24. 158
      src/Squidex/app/features/apps/pages/apps-page.component.html
  25. 46
      src/Squidex/app/features/apps/pages/apps-page.component.scss
  26. 47
      src/Squidex/app/features/apps/pages/apps-page.component.ts
  27. 2
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  28. 8
      src/Squidex/app/features/settings/pages/backups/backups-page.component.scss
  29. 2
      src/Squidex/app/features/settings/pages/languages/language.component.html
  30. 4
      src/Squidex/app/shared/components/history-list.component.scss
  31. 1
      src/Squidex/app/shared/internal.ts
  32. 52
      src/Squidex/app/shared/services/backups.service.ts
  33. 26
      src/Squidex/app/shared/state/backups.forms.ts

4
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<IAppsByNameIndex>(SingleGrain.Id).AddAppAsync(appCreated.AppId.Id, appCreated.AppId.Name);

53
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<Guid> Apps { get; set; } = new HashSet<Guid>();
@ -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<AppCleanerGrain, State, Guid>(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);
}
}
}

39
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<Instant> 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<IBackupJob>().ToList());
}
private static bool ShouldUpdate(Instant lastTimestamp, Instant now)
{
return (now - lastTimestamp) >= UpdateDuration;
}
private bool IsRunning()
{
return state.Jobs.Any(x => !x.Stopped.HasValue);

2
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<IEvent> @event, Guid appId, BackupReader reader)
{
return TaskHelper.Done;

11
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)
{

31
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)
{
}
}
}

11
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())
{

50
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);
}
}
}

66
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<BackupReader> 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;
}
}
}
}

2
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; }
}
}

8
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<J<IRestoreJob>> GetStateAsync();
Task<J<IRestoreJob>> GetJobAsync();
}
}

7
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<string> Log { get; }
JobStatus Status { get; }
}
}

17
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
}
}

185
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<string> store;
private readonly IEnumerable<BackupHandler> handlers;
private RefToken actor;
private RestoreState state = new RestoreState();
private IPersistence<RestoreState> 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<RestoreState, string>(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<EventData> { 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<J<IRestoreJob>> GetStateAsync()
public Task<J<IRestoreJob>> GetJobAsync()
{
return Task.FromResult<J<IRestoreJob>>(state.Job);
}

2
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; }
}
}

14
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<string> Log { get; set; }
[JsonProperty]
public bool IsFailed { get; set; }
public JobStatus Status { get; set; }
}
}

4
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<Stream>(new FileStream(tempFile, FileMode.Create, FileAccess.ReadWrite));
return Task.FromResult<Stream>(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");
}
}
}

2
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<HashSet<string>> NormalizeTagsAsync(HashSet<string> names, HashSet<string> ids)

16
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<Task> 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<TInput, TOutput> ToDefault<TInput, TOutput>(this Action<TInput> action)

45
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
{
/// <summary>
/// The uri to load from.
/// </summary>
[Required]
public Uri Uri { get; set; }
/// <summary>
/// The status text.
/// </summary>
[Required]
public string Status { get; set; }
/// <summary>
/// Indicates when the restore operation has been started.
/// </summary>
public Instant Started { get; set; }
/// <summary>
/// Indicates if the restore has failed.
/// </summary>
public bool IsFailed { get; set; }
public static RestoreJobDto FromJob(IRestoreJob job)
{
return SimpleMapper.Map(job, new RestoreJobDto());
}
}
}

21
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
{
/// <summary>
/// The url to the restore file.
/// </summary>
[Required]
public Uri Url { get; set; }
}
}

86
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
{
/// <summary>
/// Restores backups.
/// </summary>
[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;
}
/// <summary>
/// Get the restore jobs.
/// </summary>
/// <returns>
/// 200 => Restore job returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/restore/")]
[ProducesResponseType(typeof(RestoreJobDto), 200)]
[ApiCosts(0)]
public async Task<IActionResult> GetJob()
{
var restoreGrain = grainFactory.GetGrain<IRestoreGrain>(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);
}
/// <summary>
/// Start a new restore job.
/// </summary>
/// <param name="request">The request object.</param>
/// <returns>
/// 204 => Backup started.
/// </returns>
[HttpPost]
[Route("apps/restore/")]
[ApiCosts(0)]
public async Task<IActionResult> PostRestore([FromBody] RestoreRequest request)
{
var restoreGrain = grainFactory.GetGrain<IRestoreGrain>(User.OpenIdSubject());
await restoreGrain.RestoreAsync(request.Url);
return NoContent();
}
}
}

29
src/Squidex/Config/Domain/EntitiesServices.cs

@ -185,12 +185,23 @@ namespace Squidex.Config.Domain
private static void AddBackupHandlers(this IServiceCollection services)
{
services.AddTransient<BackupApps>();
services.AddTransient<BackupAssets>();
services.AddTransient<BackupContents>();
services.AddTransient<BackupHistory>();
services.AddTransient<BackupRules>();
services.AddTransient<BackupSchemas>();
services.AddTransientAs<BackupApps>()
.As<BackupHandler>();
services.AddTransientAs<BackupAssets>()
.As<BackupHandler>();
services.AddTransientAs<BackupContents>()
.As<BackupHandler>();
services.AddTransientAs<BackupHistory>()
.As<BackupHandler>();
services.AddTransientAs<BackupRules>()
.As<BackupHandler>();
services.AddTransientAs<BackupSchemas>()
.As<BackupHandler>();
}
public static void AddMyMigrationServices(this IServiceCollection services)
@ -198,6 +209,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<Migrator>()
.AsSelf();
services.AddTransientAs<Rebuilder>()
.AsSelf();
services.AddTransientAs<MigrationPath>()
.As<IMigrationPath>();
@ -227,9 +241,6 @@ namespace Squidex.Config.Domain
services.AddTransientAs<StopEventConsumers>()
.As<IMigration>();
services.AddTransientAs<Rebuilder>()
.AsSelf();
}
}
}

158
src/Squidex/app/features/apps/pages/apps-page.component.html

@ -1,6 +1,6 @@
<sqx-title message="Apps"></sqx-title>
<div class="apps-section">
<div class="col-left apps-section">
<h1 class="apps-title">Hi {{authState.user?.displayName}}</h1>
<div class="subtext">
@ -8,71 +8,115 @@
</div>
</div>
<ng-container *ngIf="appsState.apps | async; let apps">
<div class="apps-section">
<div class="empty" *ngIf="apps.length === 0">
<h3 class="empty-headline">You are not collaborating to any app yet</h3>
</div>
<div class="card card-href card-app float-left" *ngFor="let app of apps" [routerLink]="['/app', app.name]">
<div class="card-body">
<h4 class="card-title">{{app.name}}</h4>
<div class="card-text">
<a [routerLink]="['/app', app.name]">Edit</a>
<div class="row no-gutters">
<div class="col">
<div class="col-left">
<ng-container *ngIf="appsState.apps | async; let apps">
<div class="apps-section">
<div class="empty" *ngIf="apps.length === 0">
<h3 class="empty-headline">You are not collaborating to any app yet</h3>
</div>
<div class="card card-href card-app float-left" *ngFor="let app of apps" [routerLink]="['/app', app.name]">
<div class="card-body">
<h4 class="card-title">{{app.name}}</h4>
<div class="card-text">
<a [routerLink]="['/app', app.name]">Edit</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</ng-container>
<div class="apps-section">
<div class="card card-template card-href" (click)="createNewApp('')">
<div class="card-body">
<div class="card-image">
<img src="/images/add-app.png" />
</div>
<h4 class="card-title">New App</h4>
<div class="card-text">
Create a new blank app without content and schemas.
</div>
</div>
</div>
<div class="card card-template card-href" (click)="createNewApp('Blog')">
<div class="card-body">
<div class="card-image">
<img src="/images/add-blog.png" />
</div>
<h4 class="card-title">New Blog Sample</h4>
<div class="card-text">
<div>Start with our ready to use blog.</div>
<div>
Sample Code: <a href="https://github.com/Squidex/squidex-samples/tree/master/csharp/Sample.Blog" (click)="$event.stopPropagation()" target="_blank">C#</a>
</ng-container>
<div class="apps-section">
<div class="card card-template card-href" (click)="createNewApp('')">
<div class="card-body">
<div class="card-image">
<img src="/images/add-app.png" />
</div>
<h4 class="card-title">New App</h4>
<div class="card-text">
Create a new blank app without content and schemas.
</div>
</div>
</div>
<div class="card card-template card-href" (click)="createNewApp('Blog')">
<div class="card-body">
<div class="card-image">
<img src="/images/add-blog.png" />
</div>
<h4 class="card-title">New Blog Sample</h4>
<div class="card-text">
<div>Start with our ready to use blog.</div>
<div>
Sample Code: <a href="https://github.com/Squidex/squidex-samples/tree/master/csharp/Sample.Blog" (click)="$event.stopPropagation()" target="_blank">C#</a>
</div>
</div>
</div>
</div>
<div class="card card-template card-href" (click)="createNewApp('Profile')">
<div class="card-body">
<div class="card-image">
<img src="/images/add-profile.png" />
</div>
<h4 class="card-title">New Profile Sample</h4>
<div class="card-text">
<div>Create your profile page.</div>
<div>
Sample Code: <a href="https://github.com/Squidex/squidex-samples/tree/master/csharp/Sample.Profile" (click)="$event.stopPropagation()" target="_blank">C#</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card card-template card-href" (click)="createNewApp('Profile')">
<div class="card-body">
<div class="card-image">
<img src="/images/add-profile.png" />
<div class="col col-auto col-right">
<h3>Restore Backup</h3>
<ng-container *ngIf="restoreJob; let job">
<div class="card restore-card">
<div class="card-header">
<div class="row no-gutters">
<div class="col col-auto pr-2">
<div *ngIf="!job.isFailed" class="restore-status restore-status-pending spin">
<i class="icon-hour-glass"></i>
</div>
<div *ngIf="job.isFailed" class="restore-status restore-status-failed">
<i class="icon-exclamation"></i>
</div>
</div>
<div class="col">
<h3>Restore</h3>
</div>
</div>
</div>
<div class="card-body">
Status: {{job.status}}
</div>
</div>
</ng-container>
<h4 class="card-title">New Profile Sample</h4>
<div class="card-text">
<div>Create your profile page.</div>
<div>
Sample Code: <a href="https://github.com/Squidex/squidex-samples/tree/master/csharp/Sample.Profile" (click)="$event.stopPropagation()" target="_blank">C#</a>
<form [formGroup]="restoreForm.form" class="mt-2" (submit)="restore()">
<div class="row no-gutters">
<div class="col">
<input class="form-control form-control-sm" name="url" formControlName="url" placeholder="Url" />
</div>
<div class="col col-auto pl-1">
<button type="submit" class="btn btn-sm btn-success" [disabled]="restoreForm.hasNoUrl | async">Restore</button>
</div>
</div>
</div>
</form>
</div>
</div>

46
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;
}
}
}

47
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();

2
src/Squidex/app/features/settings/pages/backups/backups-page.component.html

@ -33,7 +33,7 @@
</div>
<div class="table-items-row" *ngFor="let backup of backups; trackBy: trackByBackup">
<div class="row no-gutter">
<div class="row">
<div class="col col-auto">
<div *ngIf="!backup.stopped" class="backup-status backup-status-pending spin">
<i class="icon-hour-glass"></i>

8
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;

2
src/Squidex/app/features/settings/pages/languages/language.component.html

@ -40,7 +40,7 @@
<div class="col col-9">
<div class="fallback-languages" [sqxSortModel]="fallbackLanguages.mutableValues" *ngIf="fallbackLanguages.length > 0">
<div class="fallback-language" *ngFor="let language of fallbackLanguages">
<div class="row no-gutter">
<div class="row">
<div class="col">
{{language.englishName}}
</div>

4
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;

1
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';

52
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<RestoreDto | null> {
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<any> {
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<any> {
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<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups/${id}`);

26
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<FormGroup> {
public hasNoUrl =
this.form.controls['url'].valueChanges.pipe(startWith(null), map(x => !x));
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
url: [null,
[
Validators.required
]
]
}));
}
}
Loading…
Cancel
Save