Browse Source

Separate page for restore and refactoring.

pull/311/head
Sebastian 7 years ago
parent
commit
d3481917b8
  1. 4
      src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs
  2. 2
      src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs
  3. 79
      src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs
  4. 2
      src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs
  5. 62
      src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs
  6. 2
      src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs
  7. 81
      src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs
  8. 2
      src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs
  9. 2
      src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs
  10. 2
      src/Squidex.Domain.Apps.Entities/History/BackupHistory.cs
  11. 2
      src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs
  12. 2
      src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs
  13. 4
      src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs
  14. 16
      src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs
  15. 1
      src/Squidex/app/features/apps/declarations.ts
  16. 9
      src/Squidex/app/features/apps/module.ts
  17. 54
      src/Squidex/app/features/apps/pages/apps-page.component.html
  18. 46
      src/Squidex/app/features/apps/pages/apps-page.component.scss
  19. 47
      src/Squidex/app/features/apps/pages/apps-page.component.ts
  20. 55
      src/Squidex/app/features/apps/pages/restore-page.component.html
  21. 59
      src/Squidex/app/features/apps/pages/restore-page.component.scss
  22. 66
      src/Squidex/app/features/apps/pages/restore-page.component.ts
  23. 1
      src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss
  24. 6
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  25. 8
      src/Squidex/app/shared/services/backups.service.spec.ts
  26. 14
      src/Squidex/app/shared/services/backups.service.ts
  27. 4
      src/Squidex/app/shared/state/backups.state.spec.ts

4
src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs

@ -27,6 +27,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
private bool isReserved; private bool isReserved;
private AppCreated appCreated; private AppCreated appCreated;
public override string Name { get; } = "Apps";
public BackupApps(IStore<Guid> store, IGrainFactory grainFactory) public BackupApps(IStore<Guid> store, IGrainFactory grainFactory)
: base(store) : base(store)
{ {
@ -78,7 +80,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
} }
} }
public override async Task CleanupRestoreAsync(Guid appId, Exception exception) public override async Task CleanupRestoreAsync(Guid appId)
{ {
if (isReserved) if (isReserved)
{ {

2
src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs

@ -32,6 +32,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly ITagService tagService; private readonly ITagService tagService;
private readonly IEventDataFormatter eventDataFormatter; private readonly IEventDataFormatter eventDataFormatter;
public override string Name { get; } = "Assets";
public BackupAssets(IStore<Guid> store, public BackupAssets(IStore<Guid> store,
IEventDataFormatter eventDataFormatter, IEventDataFormatter eventDataFormatter,
IAssetStore assetStore, IAssetStore assetStore,

79
src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs

@ -12,6 +12,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using NodaTime; using NodaTime;
using Orleans.Concurrency; using Orleans.Concurrency;
using Squidex.Domain.Apps.Entities.Backup.Helpers;
using Squidex.Domain.Apps.Entities.Backup.State; using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -20,6 +21,7 @@ using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Backup namespace Squidex.Domain.Apps.Entities.Backup
{ {
@ -78,33 +80,16 @@ namespace Squidex.Domain.Apps.Entities.Backup
persistence = store.WithSnapshots<BackupState, Guid>(GetType(), key, s => state = s); persistence = store.WithSnapshots<BackupState, Guid>(GetType(), key, s => state = s);
await ReadAsync(); await ReadAsync();
await CleanupAsync();
}
public async Task ClearAsync()
{
foreach (var job in state.Jobs)
{
await CleanupArchiveAsync(job);
await CleanupBackupAsync(job);
}
state = new BackupState(); RecoverAfterRestart();
await persistence.DeleteAsync();
}
private async Task ReadAsync()
{
await persistence.ReadAsync();
} }
private async Task WriteAsync() private void RecoverAfterRestart()
{ {
await persistence.WriteSnapshotAsync(state); RecoverAfterRestartAsync().Forget();
} }
private async Task CleanupAsync() private async Task RecoverAfterRestartAsync()
{ {
foreach (var job in state.Jobs) foreach (var job in state.Jobs)
{ {
@ -112,8 +97,8 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
job.Stopped = clock.GetCurrentInstant(); job.Stopped = clock.GetCurrentInstant();
await CleanupArchiveAsync(job); await Safe.DeleteAsync(backupArchiveLocation, job.Id, log);
await CleanupBackupAsync(job); await Safe.DeleteAsync(assetStore, job.Id, log);
job.Status = JobStatus.Failed; job.Status = JobStatus.Failed;
@ -122,36 +107,6 @@ namespace Squidex.Domain.Apps.Entities.Backup
} }
} }
private async Task CleanupBackupAsync(BackupStateJob job)
{
try
{
await assetStore.DeleteAsync(job.Id.ToString(), 0, null);
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "deleteBackup")
.WriteProperty("status", "failed")
.WriteProperty("backupId", job.Id.ToString()));
}
}
private async Task CleanupArchiveAsync(BackupStateJob job)
{
try
{
await backupArchiveLocation.DeleteArchiveAsync(job.Id);
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "deleteArchive")
.WriteProperty("status", "failed")
.WriteProperty("backupId", job.Id.ToString()));
}
}
public async Task RunAsync() public async Task RunAsync()
{ {
if (currentTask != null) if (currentTask != null)
@ -220,6 +175,8 @@ namespace Squidex.Domain.Apps.Entities.Backup
await assetStore.UploadAsync(job.Id.ToString(), 0, null, stream, currentTask.Token); await assetStore.UploadAsync(job.Id.ToString(), 0, null, stream, currentTask.Token);
} }
job.Status = JobStatus.Completed;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -232,7 +189,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
} }
finally finally
{ {
await CleanupArchiveAsync(job); await Safe.DeleteAsync(backupArchiveLocation, job.Id, log);
job.Stopped = clock.GetCurrentInstant(); job.Stopped = clock.GetCurrentInstant();
@ -247,7 +204,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
if (ShouldUpdate(lastTimestamp, now)) if ((now - lastTimestamp) >= UpdateDuration)
{ {
lastTimestamp = now; lastTimestamp = now;
@ -272,8 +229,8 @@ namespace Squidex.Domain.Apps.Entities.Backup
} }
else else
{ {
await CleanupArchiveAsync(job); await Safe.DeleteAsync(backupArchiveLocation, job.Id, log);
await CleanupBackupAsync(job); await Safe.DeleteAsync(assetStore, job.Id, log);
state.Jobs.Remove(job); state.Jobs.Remove(job);
@ -286,14 +243,14 @@ namespace Squidex.Domain.Apps.Entities.Backup
return J.AsTask(state.Jobs.OfType<IBackupJob>().ToList()); return J.AsTask(state.Jobs.OfType<IBackupJob>().ToList());
} }
private static bool ShouldUpdate(Instant lastTimestamp, Instant now) private async Task ReadAsync()
{ {
return (now - lastTimestamp) >= UpdateDuration; await persistence.ReadAsync();
} }
private bool IsRunning() private async Task WriteAsync()
{ {
return state.Jobs.Any(x => !x.Stopped.HasValue); await persistence.WriteSnapshotAsync(state);
} }
} }
} }

2
src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs

@ -36,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
return TaskHelper.Done; return TaskHelper.Done;
} }
public virtual Task CleanupRestoreAsync(Guid appId, Exception exception) public virtual Task CleanupRestoreAsync(Guid appId)
{ {
return TaskHelper.Done; return TaskHelper.Done;
} }

62
src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs

@ -0,0 +1,62 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Backup.Helpers
{
public static class Safe
{
public static async Task DeleteAsync(IBackupArchiveLocation backupArchiveLocation, Guid id, ISemanticLog log)
{
try
{
await backupArchiveLocation.DeleteArchiveAsync(id);
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "deleteArchive")
.WriteProperty("status", "failed")
.WriteProperty("operationId", id.ToString()));
}
}
public static async Task DeleteAsync(IAssetStore assetStore, Guid id, ISemanticLog log)
{
try
{
await assetStore.DeleteAsync(id.ToString(), 0, null);
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "deleteBackup")
.WriteProperty("status", "failed")
.WriteProperty("operationId", id.ToString()));
}
}
public static async Task CleanupRestoreAsync(BackupHandler handler, Guid appId, Guid id, ISemanticLog log)
{
try
{
await handler.CleanupRestoreAsync(appId);
}
catch (Exception ex)
{
log.LogError(ex, w => w
.WriteProperty("action", "cleanupRestore")
.WriteProperty("status", "failed")
.WriteProperty("operationId", id.ToString()));
}
}
}
}

2
src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs

@ -19,8 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Backup
Task DeleteAsync(Guid id); Task DeleteAsync(Guid id);
Task ClearAsync();
Task<J<List<IBackupJob>>> GetStateAsync(); Task<J<List<IBackupJob>>> GetStateAsync();
} }
} }

81
src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs

@ -40,6 +40,11 @@ namespace Squidex.Domain.Apps.Entities.Backup
private RestoreState state = new RestoreState(); private RestoreState state = new RestoreState();
private IPersistence<RestoreState> persistence; private IPersistence<RestoreState> persistence;
private RestoreStateJob CurrentJob
{
get { return state.Job; }
}
public RestoreGrain( public RestoreGrain(
IAssetStore assetStore, IAssetStore assetStore,
IBackupArchiveLocation backupArchiveLocation, IBackupArchiveLocation backupArchiveLocation,
@ -92,10 +97,12 @@ namespace Squidex.Domain.Apps.Entities.Backup
private async Task RecoverAfterRestartAsync() private async Task RecoverAfterRestartAsync()
{ {
if (state.Job?.Status == JobStatus.Started) if (CurrentJob?.Status == JobStatus.Started)
{ {
Log("Failed due application restart"); Log("Failed due application restart");
CurrentJob.Status = JobStatus.Failed;
await CleanupAsync(); await CleanupAsync();
await WriteAsync(); await WriteAsync();
} }
@ -105,7 +112,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
Guard.NotNull(url, nameof(url)); Guard.NotNull(url, nameof(url));
if (state.Job?.Status == JobStatus.Started) if (CurrentJob?.Status == JobStatus.Started)
{ {
throw new DomainException("A restore operation is already running."); throw new DomainException("A restore operation is already running.");
} }
@ -134,17 +141,24 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
try try
{ {
Log("Started. The restore process has the following steps:");
Log(" * Download backup");
Log(" * Restore events and attachments.");
Log(" * Restore all objects like app, schemas and contents");
Log(" * Complete the restore operation for all objects");
log.LogInformation(w => w log.LogInformation(w => w
.WriteProperty("action", "restore") .WriteProperty("action", "restore")
.WriteProperty("status", "started") .WriteProperty("status", "started")
.WriteProperty("url", state.Job.Uri.ToString())); .WriteProperty("operationId", CurrentJob.Id.ToString())
.WriteProperty("url", CurrentJob.Uri.ToString()));
using (Profiler.Trace("Download")) using (Profiler.Trace("Download"))
{ {
await DownloadAsync(); await DownloadAsync();
} }
using (var reader = await backupArchiveLocation.OpenArchiveAsync(state.Job.Id)) using (var reader = await backupArchiveLocation.OpenArchiveAsync(CurrentJob.Id))
{ {
using (Profiler.Trace("ReadEvents")) using (Profiler.Trace("ReadEvents"))
{ {
@ -155,7 +169,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.RestoreAsync))) using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.RestoreAsync)))
{ {
await handler.RestoreAsync(state.Job.AppId, reader); await handler.RestoreAsync(CurrentJob.AppId, reader);
} }
Log($"Restored {handler.Name}"); Log($"Restored {handler.Name}");
@ -165,20 +179,23 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.CompleteRestoreAsync))) using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.CompleteRestoreAsync)))
{ {
await handler.CompleteRestoreAsync(state.Job.AppId, reader); await handler.CompleteRestoreAsync(CurrentJob.AppId, reader);
} }
Log($"Completed {handler.Name}"); Log($"Completed {handler.Name}");
} }
} }
state.Job.Status = JobStatus.Failed; CurrentJob.Status = JobStatus.Completed;
Log("Completed, Yeah!");
log.LogInformation(w => log.LogInformation(w =>
{ {
w.WriteProperty("action", "restore"); w.WriteProperty("action", "restore");
w.WriteProperty("status", "completed"); w.WriteProperty("status", "completed");
w.WriteProperty("url", state.Job.Uri.ToString()); w.WriteProperty("operationId", CurrentJob.Id.ToString());
w.WriteProperty("url", CurrentJob.Uri.ToString());
Profiler.Session?.Write(w); Profiler.Session?.Write(w);
}); });
@ -194,28 +211,24 @@ namespace Squidex.Domain.Apps.Entities.Backup
Log("Failed with internal error"); Log("Failed with internal error");
} }
try
{
await CleanupAsync(ex); await CleanupAsync(ex);
}
catch (Exception ex2)
{
ex = ex2;
}
state.Job.Status = JobStatus.Failed; CurrentJob.Status = JobStatus.Failed;
log.LogError(ex, w => log.LogError(ex, w =>
{ {
w.WriteProperty("action", "retore"); w.WriteProperty("action", "retore");
w.WriteProperty("status", "failed"); w.WriteProperty("status", "failed");
w.WriteProperty("url", state.Job.Uri.ToString()); w.WriteProperty("operationId", CurrentJob.Id.ToString());
w.WriteProperty("url", CurrentJob.Uri.ToString());
Profiler.Session?.Write(w); Profiler.Session?.Write(w);
}); });
} }
finally finally
{ {
CurrentJob.Stopped = clock.GetCurrentInstant();
await WriteAsync(); await WriteAsync();
} }
} }
@ -223,16 +236,16 @@ namespace Squidex.Domain.Apps.Entities.Backup
private async Task CleanupAsync(Exception exception = null) private async Task CleanupAsync(Exception exception = null)
{ {
await backupArchiveLocation.DeleteArchiveAsync(state.Job.Id); await Safe.DeleteAsync(backupArchiveLocation, CurrentJob.Id, log);
if (state.Job.AppId != Guid.Empty) if (CurrentJob.AppId != Guid.Empty)
{ {
foreach (var handler in handlers) foreach (var handler in handlers)
{ {
await handler.CleanupRestoreAsync(state.Job.AppId, exception); await Safe.CleanupRestoreAsync(handler, CurrentJob.AppId, CurrentJob.Id, log);
} }
await appCleaner.EnqueueAppAsync(state.Job.AppId); await appCleaner.EnqueueAppAsync(CurrentJob.AppId);
} }
} }
@ -240,7 +253,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
Log("Downloading Backup"); Log("Downloading Backup");
await backupArchiveLocation.DownloadAsync(state.Job.Uri, state.Job.Id); await backupArchiveLocation.DownloadAsync(CurrentJob.Uri, CurrentJob.Id);
Log("Downloaded Backup"); Log("Downloaded Backup");
} }
@ -255,21 +268,22 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
squidexEvent.Actor = actor; squidexEvent.Actor = actor;
} }
else if (@event.Payload is AppCreated appCreated)
if (@event.Payload is AppCreated appCreated)
{ {
state.Job.AppId = appCreated.AppId.Id; CurrentJob.AppId = appCreated.AppId.Id;
await CheckCleanupStatus(); await CheckCleanupStatus();
} }
foreach (var handler in handlers) foreach (var handler in handlers)
{ {
await handler.RestoreEventAsync(@event, state.Job.AppId, reader); await handler.RestoreEventAsync(@event, CurrentJob.AppId, reader);
} }
await eventStore.AppendAsync(Guid.NewGuid(), storedEvent.StreamName, new List<EventData> { storedEvent.Data }); await eventStore.AppendAsync(Guid.NewGuid(), storedEvent.StreamName, new List<EventData> { storedEvent.Data });
Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments."); Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments.", true);
}); });
Log("Reading events completed."); Log("Reading events completed.");
@ -279,7 +293,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
var cleaner = grainFactory.GetGrain<IAppCleanerGrain>(SingleGrain.Id); var cleaner = grainFactory.GetGrain<IAppCleanerGrain>(SingleGrain.Id);
var status = await cleaner.GetStatusAsync(state.Job.AppId); var status = await cleaner.GetStatusAsync(CurrentJob.AppId);
if (status == CleanerStatus.Cleaning) if (status == CleanerStatus.Cleaning)
{ {
@ -292,9 +306,16 @@ namespace Squidex.Domain.Apps.Entities.Backup
} }
} }
private void Log(string message) private void Log(string message, bool replace = false)
{
if (replace && CurrentJob.Log.Count > 0)
{ {
state.Job.Log.Add($"{clock.GetCurrentInstant()}: {message}"); CurrentJob.Log[CurrentJob.Log.Count - 1] = $"{clock.GetCurrentInstant()}: {message}";
}
else
{
CurrentJob.Log.Add($"{clock.GetCurrentInstant()}: {message}");
}
} }
private async Task ReadAsync() private async Task ReadAsync()
@ -309,7 +330,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
public Task<J<IRestoreJob>> GetJobAsync() public Task<J<IRestoreJob>> GetJobAsync()
{ {
return Task.FromResult<J<IRestoreJob>>(state.Job); return Task.FromResult<J<IRestoreJob>>(CurrentJob);
} }
} }
} }

2
src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs

@ -30,7 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Backup.State
public Instant? Stopped { get; set; } public Instant? Stopped { get; set; }
[JsonProperty] [JsonProperty]
public List<string> Log { get; set; } public List<string> Log { get; set; } = new List<string>();
[JsonProperty] [JsonProperty]
public JobStatus Status { get; set; } public JobStatus Status { get; set; }

2
src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs

@ -24,6 +24,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly HashSet<Guid> contentIds = new HashSet<Guid>(); private readonly HashSet<Guid> contentIds = new HashSet<Guid>();
private readonly IContentRepository contentRepository; private readonly IContentRepository contentRepository;
public override string Name { get; } = "Contents";
public BackupContents(IStore<Guid> store, IContentRepository contentRepository) public BackupContents(IStore<Guid> store, IContentRepository contentRepository)
: base(store) : base(store)
{ {

2
src/Squidex.Domain.Apps.Entities/History/BackupHistory.cs

@ -17,6 +17,8 @@ namespace Squidex.Domain.Apps.Entities.History
{ {
private readonly IHistoryEventRepository historyEventRepository; private readonly IHistoryEventRepository historyEventRepository;
public override string Name { get; } = "History";
public BackupHistory(IHistoryEventRepository historyEventRepository) public BackupHistory(IHistoryEventRepository historyEventRepository)
{ {
Guard.NotNull(historyEventRepository, nameof(historyEventRepository)); Guard.NotNull(historyEventRepository, nameof(historyEventRepository));

2
src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs

@ -25,6 +25,8 @@ namespace Squidex.Domain.Apps.Entities.Rules
private readonly HashSet<Guid> ruleIds = new HashSet<Guid>(); private readonly HashSet<Guid> ruleIds = new HashSet<Guid>();
private readonly IGrainFactory grainFactory; private readonly IGrainFactory grainFactory;
public override string Name { get; } = "Rules";
public BackupRules(IStore<Guid> store, IGrainFactory grainFactory) public BackupRules(IStore<Guid> store, IGrainFactory grainFactory)
: base(store) : base(store)
{ {

2
src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs

@ -29,6 +29,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas
private readonly FieldRegistry fieldRegistry; private readonly FieldRegistry fieldRegistry;
private readonly IGrainFactory grainFactory; private readonly IGrainFactory grainFactory;
public override string Name { get; } = "Schemas";
public BackupSchemas(IStore<Guid> store, FieldRegistry fieldRegistry, IGrainFactory grainFactory) public BackupSchemas(IStore<Guid> store, FieldRegistry fieldRegistry, IGrainFactory grainFactory)
: base(store) : base(store)
{ {

4
src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs

@ -40,9 +40,9 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models
public int HandledAssets { get; set; } public int HandledAssets { get; set; }
/// <summary> /// <summary>
/// Indicates if the job has failed. /// The status of the operation.
/// </summary> /// </summary>
public bool IsFailed { get; set; } public JobStatus Status { get; set; }
public static BackupJobDto FromBackup(IBackupJob backup) public static BackupJobDto FromBackup(IBackupJob backup)
{ {

16
src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Backup;
@ -22,20 +23,25 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models
public Uri Uri { get; set; } public Uri Uri { get; set; }
/// <summary> /// <summary>
/// The status text. /// The status log.
/// </summary> /// </summary>
[Required] [Required]
public string Status { get; set; } public List<string> Log { get; set; }
/// <summary> /// <summary>
/// Indicates when the restore operation has been started. /// The time when the job has been started.
/// </summary> /// </summary>
public Instant Started { get; set; } public Instant Started { get; set; }
/// <summary> /// <summary>
/// Indicates if the restore has failed. /// The time when the job has been stopped.
/// </summary> /// </summary>
public bool IsFailed { get; set; } public Instant? Stopped { get; set; }
/// <summary>
/// The status of the operation.
/// </summary>
public JobStatus Status { get; set; }
public static RestoreJobDto FromJob(IRestoreJob job) public static RestoreJobDto FromJob(IRestoreJob job)
{ {

1
src/Squidex/app/features/apps/declarations.ts

@ -7,3 +7,4 @@
export * from './pages/apps-page.component'; export * from './pages/apps-page.component';
export * from './pages/onboarding-dialog.component'; export * from './pages/onboarding-dialog.component';
export * from './pages/restore-page.component';

9
src/Squidex/app/features/apps/module.ts

@ -12,13 +12,17 @@ import { SqxFrameworkModule, SqxSharedModule } from '@app/shared';
import { import {
AppsPageComponent, AppsPageComponent,
OnboardingDialogComponent OnboardingDialogComponent,
RestorePageComponent
} from './declarations'; } from './declarations';
const routes: Routes = [ const routes: Routes = [
{ {
path: '', path: '',
component: AppsPageComponent component: AppsPageComponent
}, {
path: 'restore',
component: RestorePageComponent
} }
]; ];
@ -30,7 +34,8 @@ const routes: Routes = [
], ],
declarations: [ declarations: [
AppsPageComponent, AppsPageComponent,
OnboardingDialogComponent OnboardingDialogComponent,
RestorePageComponent
] ]
}) })
export class SqxFeatureAppsModule { } export class SqxFeatureAppsModule { }

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

@ -1,6 +1,6 @@
<sqx-title message="Apps"></sqx-title> <sqx-title message="Apps"></sqx-title>
<div class="col-left apps-section"> <div class="apps-section">
<h1 class="apps-title">Hi {{authState.user?.displayName}}</h1> <h1 class="apps-title">Hi {{authState.user?.displayName}}</h1>
<div class="subtext"> <div class="subtext">
@ -8,10 +8,7 @@
</div> </div>
</div> </div>
<div class="row no-gutters"> <ng-container *ngIf="appsState.apps | async; let apps">
<div class="col">
<div class="col-left">
<ng-container *ngIf="appsState.apps | async; let apps">
<div class="apps-section"> <div class="apps-section">
<div class="empty" *ngIf="apps.length === 0"> <div class="empty" *ngIf="apps.length === 0">
<h3 class="empty-headline">You are not collaborating to any app yet</h3> <h3 class="empty-headline">You are not collaborating to any app yet</h3>
@ -27,9 +24,9 @@
</div> </div>
</div> </div>
</div> </div>
</ng-container> </ng-container>
<div class="apps-section"> <div class="apps-section">
<div class="card card-template card-href" (click)="createNewApp('')"> <div class="card card-template card-href" (click)="createNewApp('')">
<div class="card-body"> <div class="card-body">
<div class="card-image"> <div class="card-image">
@ -77,47 +74,10 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<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>
<form [formGroup]="restoreForm.form" class="mt-2" (submit)="restore()"> <div class="apps-section">
<div class="row no-gutters"> <a class="restore-link" [routerLink]="['/app/restore']">Restore app from backup</a>
<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>
</form>
</div>
</div> </div>
<ng-container *sqxModalView="addAppDialog;onRoot:true"> <ng-container *sqxModalView="addAppDialog;onRoot:true">

46
src/Squidex/app/features/apps/pages/apps-page.component.scss

@ -12,20 +12,13 @@
padding-top: 2rem; padding-top: 2rem;
padding-right: 1.25rem; padding-right: 1.25rem;
padding-bottom: 0; padding-bottom: 0;
padding-left: $size-sidebar-width + .25rem;
display: block; display: block;
} }
} }
.col-left { .restore-link {
padding-left: $size-sidebar-width + .25rem; font-size: 0.8rem;
}
.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 { .card {
@ -91,36 +84,3 @@
} }
} }
} }
$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,21 +5,15 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { take } from 'rxjs/operators';
import { Subscription, timer } from 'rxjs';
import { switchMap, take } from 'rxjs/operators';
import { import {
AppsState, AppsState,
AuthService, AuthService,
BackupsService,
DialogModel, DialogModel,
DialogService,
ModalModel, ModalModel,
OnboardingService, OnboardingService
RestoreDto,
RestoreForm
} from '@app/shared'; } from '@app/shared';
@Component({ @Component({
@ -27,38 +21,20 @@ import {
styleUrls: ['./apps-page.component.scss'], styleUrls: ['./apps-page.component.scss'],
templateUrl: './apps-page.component.html' templateUrl: './apps-page.component.html'
}) })
export class AppsPageComponent implements OnDestroy, OnInit { export class AppsPageComponent implements OnInit {
private timerSubscription: Subscription;
public addAppDialog = new DialogModel(); public addAppDialog = new DialogModel();
public addAppTemplate = ''; public addAppTemplate = '';
public restoreJob: RestoreDto | null;
public restoreForm = new RestoreForm(this.formBuilder);
public onboardingModal = new ModalModel(); public onboardingModal = new ModalModel();
constructor( constructor(
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly authState: AuthService, public readonly authState: AuthService,
private readonly backupsService: BackupsService,
private readonly dialogs: DialogService,
private readonly formBuilder: FormBuilder,
private readonly onboardingService: OnboardingService private readonly onboardingService: OnboardingService
) { ) {
} }
public ngOnDestroy() {
this.timerSubscription.unsubscribe();
}
public ngOnInit() { public ngOnInit() {
this.timerSubscription =
timer(0, 3000).pipe(switchMap(t => this.backupsService.getRestore()))
.subscribe(dto => {
this.restoreJob = dto;
});
this.appsState.apps.pipe( this.appsState.apps.pipe(
take(1)) take(1))
.subscribe(apps => { .subscribe(apps => {
@ -69,21 +45,6 @@ export class AppsPageComponent implements OnDestroy, 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) { public createNewApp(template: string) {
this.addAppTemplate = template; this.addAppTemplate = template;
this.addAppDialog.show(); this.addAppDialog.show();

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

@ -0,0 +1,55 @@
<sqx-title message="Restore Backup"></sqx-title>
<div class="container">
<ng-container *ngIf="restoreJob; let job">
<div class="card section">
<div class="card-header">
<div class="row no-gutters">
<div class="col col-auto pr-2">
<div *ngIf="job.status === 'Started'" class="restore-status restore-status-pending spin">
<i class="icon-hour-glass"></i>
</div>
<div *ngIf="job.status === 'Failed'" class="restore-status restore-status-failed">
<i class="icon-exclamation"></i>
</div>
<div *ngIf="job.status === 'Completed'" class="restore-status restore-status-success">
<i class="icon-checkmark"></i>
</div>
</div>
<div class="col">
<h3>Last Restore Operation</h3>
</div>
</div>
</div>
<div class="card-body">
<div *ngFor="let row of job.log">
{{row}}
</div>
</div>
<div class="card-footer">
<div class="row">
<div class="col">
Started: {{job.started | sqxISODate}}
</div>
<div class="col text-right" *ngIf="job.stopped">
Stopped: {{job.stopped | sqxISODate}}
</div>
</div>
</div>
</div>
</ng-container>
<div class="table-items-row">
<form [formGroup]="restoreForm.form" (submit)="restore()">
<div class="row no-gutters">
<div class="col">
<input class="form-control" name="url" formControlName="url" placeholder="Url to backup" />
</div>
<div class="col col-auto pl-1">
<button type="submit" class="btn btn-success" [disabled]="restoreForm.hasNoUrl | async">Restore Backup</button>
</div>
</div>
</form>
</div>
</div>

59
src/Squidex/app/features/apps/pages/restore-page.component.scss

@ -0,0 +1,59 @@
@import '_vars';
@import '_mixins';
$circle-size: 2rem;
h3 {
margin: 0;
}
.section {
margin-bottom: .8rem;
}
.container {
padding-top: 2rem;
}
.card {
&-header {
h3 {
line-height: $circle-size;
}
}
&-body {
font-family: monospace;
background: $color-border;
max-height: 400px;
min-height: 300px;
overflow-y: scroll;
}
}
.restore {
&-status {
& {
@include circle($circle-size);
line-height: $circle-size + .1rem;
text-align: center;
font-size: .6 * $circle-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;
}
}
}

66
src/Squidex/app/features/apps/pages/restore-page.component.ts

@ -0,0 +1,66 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Subscription, timer } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import {
AuthService,
BackupsService,
DialogService,
RestoreDto,
RestoreForm
} from '@app/shared';
@Component({
selector: 'sqx-restore-page',
styleUrls: ['./restore-page.component.scss'],
templateUrl: './restore-page.component.html'
})
export class RestorePageComponent implements OnDestroy, OnInit {
private timerSubscription: Subscription;
public restoreJob: RestoreDto | null;
public restoreForm = new RestoreForm(this.formBuilder);
constructor(
public readonly authState: AuthService,
private readonly backupsService: BackupsService,
private readonly dialogs: DialogService,
private readonly formBuilder: FormBuilder
) {
}
public ngOnDestroy() {
this.timerSubscription.unsubscribe();
}
public ngOnInit() {
this.timerSubscription =
timer(0, 1000).pipe(switchMap(() => this.backupsService.getRestore()))
.subscribe(dto => {
this.restoreJob = dto;
});
}
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);
});
}
}
}

1
src/Squidex/app/features/rules/pages/events/rule-events-page.component.scss

@ -18,6 +18,7 @@ h3 {
&-dump { &-dump {
margin-top: 1rem; margin-top: 1rem;
font-family: monospace;
font-size: .8rem; font-size: .8rem;
font-weight: normal; font-weight: normal;
height: 20rem; height: 20rem;

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

@ -35,13 +35,13 @@
<div class="table-items-row" *ngFor="let backup of backups; trackBy: trackByBackup"> <div class="table-items-row" *ngFor="let backup of backups; trackBy: trackByBackup">
<div class="row"> <div class="row">
<div class="col col-auto"> <div class="col col-auto">
<div *ngIf="!backup.stopped" class="backup-status backup-status-pending spin"> <div *ngIf="backup.status === 'Started'" class="backup-status backup-status-pending spin">
<i class="icon-hour-glass"></i> <i class="icon-hour-glass"></i>
</div> </div>
<div *ngIf="backup.stopped && backup.isFailed" class="backup-status backup-status-failed"> <div *ngIf="backup.status === 'Failed'" class="backup-status backup-status-failed">
<i class="icon-exclamation"></i> <i class="icon-exclamation"></i>
</div> </div>
<div *ngIf="backup.stopped && !backup.isFailed" class="backup-status backup-status-success"> <div *ngIf="backup.status === 'Completed'" class="backup-status backup-status-success">
<i class="icon-checkmark"></i> <i class="icon-checkmark"></i>
</div> </div>
</div> </div>

8
src/Squidex/app/shared/services/backups.service.spec.ts

@ -55,7 +55,7 @@ describe('BackupsService', () => {
stopped: '2017-02-04', stopped: '2017-02-04',
handledEvents: 13, handledEvents: 13,
handledAssets: 17, handledAssets: 17,
isFailed: false status: 'Failed'
}, },
{ {
id: '2', id: '2',
@ -63,14 +63,14 @@ describe('BackupsService', () => {
stopped: null, stopped: null,
handledEvents: 23, handledEvents: 23,
handledAssets: 27, handledAssets: 27,
isFailed: true status: 'Completed'
} }
]); ]);
expect(backups!).toEqual( expect(backups!).toEqual(
[ [
new BackupDto('1', DateTime.parseISO_UTC('2017-02-03'), DateTime.parseISO_UTC('2017-02-04'), 13, 17, false), new BackupDto('1', DateTime.parseISO_UTC('2017-02-03'), DateTime.parseISO_UTC('2017-02-04'), 13, 17, 'Failed'),
new BackupDto('2', DateTime.parseISO_UTC('2018-02-03'), null, 23, 27, true) new BackupDto('2', DateTime.parseISO_UTC('2018-02-03'), null, 23, 27, 'Completed')
]); ]);
})); }));

14
src/Squidex/app/shared/services/backups.service.ts

@ -26,7 +26,7 @@ export class BackupDto extends Model {
public readonly stopped: DateTime | null, public readonly stopped: DateTime | null,
public readonly handledEvents: number, public readonly handledEvents: number,
public readonly handledAssets: number, public readonly handledAssets: number,
public readonly isFailed: boolean public readonly status: string
) { ) {
super(); super();
} }
@ -38,10 +38,11 @@ export class BackupDto extends Model {
export class RestoreDto { export class RestoreDto {
constructor( constructor(
public readonly url: string,
public readonly started: DateTime, public readonly started: DateTime,
public readonly stopped: DateTime | null,
public readonly status: string, public readonly status: string,
public readonly url: string, public readonly log: string[]
public readonly isFailed: boolean
) { ) {
} }
} }
@ -69,7 +70,7 @@ export class BackupsService {
item.stopped ? DateTime.parseISO_UTC(item.stopped) : null, item.stopped ? DateTime.parseISO_UTC(item.stopped) : null,
item.handledEvents, item.handledEvents,
item.handledAssets, item.handledAssets,
item.isFailed); item.status);
}); });
}), }),
pretifyError('Failed to load backups.')); pretifyError('Failed to load backups.'));
@ -83,10 +84,11 @@ export class BackupsService {
const body: any = response; const body: any = response;
return new RestoreDto( return new RestoreDto(
body.id,
DateTime.parseISO_UTC(body.started), DateTime.parseISO_UTC(body.started),
body.stopped ? DateTime.parseISO_UTC(body.stopped) : null,
body.status, body.status,
body.url, body.log);
body.isFailed);
}), }),
catchError(error => { catchError(error => {
if (Types.is(error, HttpErrorResponse) && error.status === 404) { if (Types.is(error, HttpErrorResponse) && error.status === 404) {

4
src/Squidex/app/shared/state/backups.state.spec.ts

@ -22,8 +22,8 @@ describe('BackupsState', () => {
const app = 'my-app'; const app = 'my-app';
const oldBackups = [ const oldBackups = [
new BackupDto('id1', DateTime.now(), null, 1, 1, false), new BackupDto('id1', DateTime.now(), null, 1, 1, 'Started'),
new BackupDto('id2', DateTime.now(), null, 2, 2, false) new BackupDto('id2', DateTime.now(), null, 2, 2, 'Started')
]; ];
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;

Loading…
Cancel
Save