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 AppCreated appCreated;
public override string Name { get; } = "Apps";
public BackupApps(IStore<Guid> store, IGrainFactory grainFactory)
: 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)
{

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 IEventDataFormatter eventDataFormatter;
public override string Name { get; } = "Assets";
public BackupAssets(IStore<Guid> store,
IEventDataFormatter eventDataFormatter,
IAssetStore assetStore,

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

@ -12,6 +12,7 @@ using System.Threading;
using System.Threading.Tasks;
using NodaTime;
using Orleans.Concurrency;
using Squidex.Domain.Apps.Entities.Backup.Helpers;
using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
@ -20,6 +21,7 @@ using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
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);
await ReadAsync();
await CleanupAsync();
}
public async Task ClearAsync()
{
foreach (var job in state.Jobs)
{
await CleanupArchiveAsync(job);
await CleanupBackupAsync(job);
}
state = new BackupState();
await persistence.DeleteAsync();
}
private async Task ReadAsync()
{
await persistence.ReadAsync();
RecoverAfterRestart();
}
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)
{
@ -112,8 +97,8 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
job.Stopped = clock.GetCurrentInstant();
await CleanupArchiveAsync(job);
await CleanupBackupAsync(job);
await Safe.DeleteAsync(backupArchiveLocation, job.Id, log);
await Safe.DeleteAsync(assetStore, job.Id, log);
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()
{
if (currentTask != null)
@ -220,6 +175,8 @@ namespace Squidex.Domain.Apps.Entities.Backup
await assetStore.UploadAsync(job.Id.ToString(), 0, null, stream, currentTask.Token);
}
job.Status = JobStatus.Completed;
}
catch (Exception ex)
{
@ -232,7 +189,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
}
finally
{
await CleanupArchiveAsync(job);
await Safe.DeleteAsync(backupArchiveLocation, job.Id, log);
job.Stopped = clock.GetCurrentInstant();
@ -247,7 +204,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
var now = clock.GetCurrentInstant();
if (ShouldUpdate(lastTimestamp, now))
if ((now - lastTimestamp) >= UpdateDuration)
{
lastTimestamp = now;
@ -272,8 +229,8 @@ namespace Squidex.Domain.Apps.Entities.Backup
}
else
{
await CleanupArchiveAsync(job);
await CleanupBackupAsync(job);
await Safe.DeleteAsync(backupArchiveLocation, job.Id, log);
await Safe.DeleteAsync(assetStore, job.Id, log);
state.Jobs.Remove(job);
@ -286,14 +243,14 @@ namespace Squidex.Domain.Apps.Entities.Backup
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;
}
public virtual Task CleanupRestoreAsync(Guid appId, Exception exception)
public virtual Task CleanupRestoreAsync(Guid appId)
{
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 ClearAsync();
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 IPersistence<RestoreState> persistence;
private RestoreStateJob CurrentJob
{
get { return state.Job; }
}
public RestoreGrain(
IAssetStore assetStore,
IBackupArchiveLocation backupArchiveLocation,
@ -92,10 +97,12 @@ namespace Squidex.Domain.Apps.Entities.Backup
private async Task RecoverAfterRestartAsync()
{
if (state.Job?.Status == JobStatus.Started)
if (CurrentJob?.Status == JobStatus.Started)
{
Log("Failed due application restart");
CurrentJob.Status = JobStatus.Failed;
await CleanupAsync();
await WriteAsync();
}
@ -105,7 +112,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
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.");
}
@ -134,17 +141,24 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
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
.WriteProperty("action", "restore")
.WriteProperty("status", "started")
.WriteProperty("url", state.Job.Uri.ToString()));
.WriteProperty("operationId", CurrentJob.Id.ToString())
.WriteProperty("url", CurrentJob.Uri.ToString()));
using (Profiler.Trace("Download"))
{
await DownloadAsync();
}
using (var reader = await backupArchiveLocation.OpenArchiveAsync(state.Job.Id))
using (var reader = await backupArchiveLocation.OpenArchiveAsync(CurrentJob.Id))
{
using (Profiler.Trace("ReadEvents"))
{
@ -155,7 +169,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
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}");
@ -165,20 +179,23 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
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}");
}
}
state.Job.Status = JobStatus.Failed;
CurrentJob.Status = JobStatus.Completed;
Log("Completed, Yeah!");
log.LogInformation(w =>
{
w.WriteProperty("action", "restore");
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);
});
@ -194,28 +211,24 @@ namespace Squidex.Domain.Apps.Entities.Backup
Log("Failed with internal error");
}
try
{
await CleanupAsync(ex);
}
catch (Exception ex2)
{
ex = ex2;
}
state.Job.Status = JobStatus.Failed;
CurrentJob.Status = JobStatus.Failed;
log.LogError(ex, w =>
{
w.WriteProperty("action", "retore");
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);
});
}
finally
{
CurrentJob.Stopped = clock.GetCurrentInstant();
await WriteAsync();
}
}
@ -223,16 +236,16 @@ namespace Squidex.Domain.Apps.Entities.Backup
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)
{
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");
await backupArchiveLocation.DownloadAsync(state.Job.Uri, state.Job.Id);
await backupArchiveLocation.DownloadAsync(CurrentJob.Uri, CurrentJob.Id);
Log("Downloaded Backup");
}
@ -255,21 +268,22 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
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();
}
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 });
Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments.");
Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments.", true);
});
Log("Reading events completed.");
@ -279,7 +293,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
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)
{
@ -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()
@ -309,7 +330,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
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; }
[JsonProperty]
public List<string> Log { get; set; }
public List<string> Log { get; set; } = new List<string>();
[JsonProperty]
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 IContentRepository contentRepository;
public override string Name { get; } = "Contents";
public BackupContents(IStore<Guid> store, IContentRepository contentRepository)
: 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;
public override string Name { get; } = "History";
public BackupHistory(IHistoryEventRepository 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 IGrainFactory grainFactory;
public override string Name { get; } = "Rules";
public BackupRules(IStore<Guid> store, IGrainFactory grainFactory)
: 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 IGrainFactory grainFactory;
public override string Name { get; } = "Schemas";
public BackupSchemas(IStore<Guid> store, FieldRegistry fieldRegistry, IGrainFactory grainFactory)
: 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; }
/// <summary>
/// Indicates if the job has failed.
/// The status of the operation.
/// </summary>
public bool IsFailed { get; set; }
public JobStatus Status { get; set; }
public static BackupJobDto FromBackup(IBackupJob backup)
{

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

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

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

@ -7,3 +7,4 @@
export * from './pages/apps-page.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 {
AppsPageComponent,
OnboardingDialogComponent
OnboardingDialogComponent,
RestorePageComponent
} from './declarations';
const routes: Routes = [
{
path: '',
component: AppsPageComponent
}, {
path: 'restore',
component: RestorePageComponent
}
];
@ -30,7 +34,8 @@ const routes: Routes = [
],
declarations: [
AppsPageComponent,
OnboardingDialogComponent
OnboardingDialogComponent,
RestorePageComponent
]
})
export class SqxFeatureAppsModule { }

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

@ -1,6 +1,6 @@
<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>
<div class="subtext">
@ -8,10 +8,7 @@
</div>
</div>
<div class="row no-gutters">
<div class="col">
<div class="col-left">
<ng-container *ngIf="appsState.apps | async; let apps">
<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>
@ -27,9 +24,9 @@
</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-body">
<div class="card-image">
@ -77,47 +74,10 @@
</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>
</div>
<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>
</form>
</div>
<div class="apps-section">
<a class="restore-link" [routerLink]="['/app/restore']">Restore app from backup</a>
</div>
<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-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;
.restore-link {
font-size: 0.8rem;
}
.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.
*/
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Subscription, timer } from 'rxjs';
import { switchMap, take } from 'rxjs/operators';
import { Component, OnInit } from '@angular/core';
import { take } from 'rxjs/operators';
import {
AppsState,
AuthService,
BackupsService,
DialogModel,
DialogService,
ModalModel,
OnboardingService,
RestoreDto,
RestoreForm
OnboardingService
} from '@app/shared';
@Component({
@ -27,38 +21,20 @@ import {
styleUrls: ['./apps-page.component.scss'],
templateUrl: './apps-page.component.html'
})
export class AppsPageComponent implements OnDestroy, OnInit {
private timerSubscription: Subscription;
export class AppsPageComponent implements OnInit {
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 => {
@ -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) {
this.addAppTemplate = template;
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 {
margin-top: 1rem;
font-family: monospace;
font-size: .8rem;
font-weight: normal;
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="row">
<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>
</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>
</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>
</div>
</div>

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

@ -55,7 +55,7 @@ describe('BackupsService', () => {
stopped: '2017-02-04',
handledEvents: 13,
handledAssets: 17,
isFailed: false
status: 'Failed'
},
{
id: '2',
@ -63,14 +63,14 @@ describe('BackupsService', () => {
stopped: null,
handledEvents: 23,
handledAssets: 27,
isFailed: true
status: 'Completed'
}
]);
expect(backups!).toEqual(
[
new BackupDto('1', DateTime.parseISO_UTC('2017-02-03'), DateTime.parseISO_UTC('2017-02-04'), 13, 17, false),
new BackupDto('2', DateTime.parseISO_UTC('2018-02-03'), null, 23, 27, true)
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, '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 handledEvents: number,
public readonly handledAssets: number,
public readonly isFailed: boolean
public readonly status: string
) {
super();
}
@ -38,10 +38,11 @@ export class BackupDto extends Model {
export class RestoreDto {
constructor(
public readonly url: string,
public readonly started: DateTime,
public readonly stopped: DateTime | null,
public readonly status: string,
public readonly url: string,
public readonly isFailed: boolean
public readonly log: string[]
) {
}
}
@ -69,7 +70,7 @@ export class BackupsService {
item.stopped ? DateTime.parseISO_UTC(item.stopped) : null,
item.handledEvents,
item.handledAssets,
item.isFailed);
item.status);
});
}),
pretifyError('Failed to load backups.'));
@ -83,10 +84,11 @@ export class BackupsService {
const body: any = response;
return new RestoreDto(
body.id,
DateTime.parseISO_UTC(body.started),
body.stopped ? DateTime.parseISO_UTC(body.stopped) : null,
body.status,
body.url,
body.isFailed);
body.log);
}),
catchError(error => {
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 oldBackups = [
new BackupDto('id1', DateTime.now(), null, 1, 1, false),
new BackupDto('id2', DateTime.now(), null, 2, 2, false)
new BackupDto('id1', DateTime.now(), null, 1, 1, 'Started'),
new BackupDto('id2', DateTime.now(), null, 2, 2, 'Started')
];
let dialogs: IMock<DialogService>;

Loading…
Cancel
Save