Files
decePubClient/Components/Admin/BackupsPanel.razor
T

448 lines
17 KiB
Plaintext
Raw Normal View History

@inherits LocalizableComponentBase
@implements IAsyncDisposable
@* The server's backups (/clientapi/admin/backups; owner decision 2026-10-07): made nightly, before each deploy and here,
downloaded as one tar through a link good for ten minutes, uploaded again in pieces straight from the browser
(js/transfer.js, never through .NET's memory), and restored: PrivaPub stops, restores the backup as it starts again,
and everyone signs in again. A restore never undoes what protects. *@
<div class="flex flex-col gap-3 w-full" data-backups>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (restarting is not null)
{
<DAlert Color="DAlertColor.Warning" data-backup-restarting>
<span>@Localizer["PrivaPub is restarting to restore {0}. Everyone, you included, signs in again once it is back.", restarting]</span>
</DAlert>
}
else
{
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || backups.Running is not null || busy)" OnClick="BackUp" data-backup-now>
<i class="ion-md-archive"></i> @Localizer["Back up now"]
</DButton>
@if (backups.Running is { } running)
{
<span class="inline-flex items-center gap-2 text-sm" data-backup-running>
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
@(running == "restore" ? Localizer["A restore is running."] : Localizer["A backup is running."])
</span>
}
<span class="ml-auto text-xs opacity-70">@Localizer["{0} free on the server's disk", backups.FreeBytes.GetFileSize(Localizer)]</span>
</div>
@if (backups.Waiting is { } waiting)
{
<DAlert Color="@(waiting.Error is null ? DAlertColor.Warning : DAlertColor.Error)" data-backup-waiting>
<span>
@Localizer["The restore of {0}, asked for by {1}, is {2}.", waiting.Backup, waiting.RequestedBy, Localizer[waiting.State]]
@if (waiting.Error is { Length: > 0 } error)
{
<br /><span class="text-xs">@error</span>
}
</span>
</DAlert>
}
@if (backups.LastRestore is { } last)
{
<div class="flex flex-col gap-1 p-3 rounded-xl neo-inset neo-2xs text-sm" data-last-restore>
@if (last.Abandoned)
{
<p><b>@Localizer["The last restore, of {0}, was abandoned; nothing changed.", last.Backup]</b></p>
<p class="text-xs">@last.Error</p>
}
else
{
<p>
<b>@Localizer["Restored {0}", last.Backup]</b>
<span class="text-xs opacity-70" title="@last.RestoredAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">@last.RestoredAt.GetPassedTime(Localizer)</span>
<span class="text-xs opacity-70">@Localizer["asked for by {0}", last.RequestedBy]</span>
</p>
<p class="text-xs">@Localizer["{0} documents in {1} collections; {2} media files brought back, {3} missing.", last.Documents, last.Collections, last.MediaRestored, last.MediaMissing]</p>
<p class="text-xs">@Localizer["Deleted again: {0} logins, {1} personas, {2} groups, {3} posts. Kept from since: {4} blocks, mutes, reports and other protective acts.", last.RootsDeleted, last.PersonasDeleted, last.GroupsDeleted, last.PostsDeleted, last.ProtectiveKept]</p>
@if (last.RootsTombstoned.Count + last.PersonasTombstoned.Count + last.GroupsTombstoned.Count > 0)
{
<p class="text-xs" data-last-restore-tombstoned>
@Localizer["Made after the backup, now deleted with their names kept: {0}.", string.Join(", ", last.RootsTombstoned.Concat(last.PersonasTombstoned).Concat(last.GroupsTombstoned))]
</p>
}
<p class="text-xs opacity-70">@Localizer["{0} posts made since answer as deleted; {1} media went to the trash; {2} sessions ended. The server as it was before is the backup {3}.", last.PostsGone, last.MediaTrashed, last.SessionsEnded, last.PreRestore]</p>
}
</div>
}
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs">
<p class="text-sm">@Localizer["Upload a backup downloaded from here (a .tar file):"]</p>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DFileInput Size="DSize.Sm" Accept=".tar,application/x-tar" OnChange="Picked" class="flex-1 min-w-48" disabled="@(IsDefaultDisabled || uploading)"
data-backup-upload />
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || uploading || pickedName is null)" OnClick="Upload" data-backup-upload-start>
<i class="ion-md-cloud-upload"></i> @Localizer["Upload"]
</DButton>
</div>
@if (uploading)
{
<DProgress Value="@uploadedPercent" Max="100" Color="DColor.Primary" data-backup-upload-progress />
<p class="text-xs opacity-70">@Localizer["{0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</p>
}
</div>
@if (backups.Backups.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["No backup yet."]</p>
}
@foreach (var backup in backups.Backups)
{
<div class="flex flex-col gap-1 p-2 rounded-lg neo-raised neo-2xs" data-backup="@backup.Id">
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<b class="min-w-0 truncate" title="@backup.Id">@backup.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)</b>
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="@KindColor(backup.Kind)">@KindName(backup.Kind)</DBadge>
<span class="text-xs opacity-70">@backup.CreatedAt.GetPassedTime(Localizer)</span>
<span class="text-xs">@((backup.Bytes + backup.MediaBytes).GetFileSize(Localizer))</span>
</div>
<p class="text-xs opacity-70">
@Localizer["{0} documents in {1} collections", backup.Documents, backup.Collections];
@(backup.DbOnly
? Localizer["{0} media files listed, kept by the nightly backups", backup.MediaFiles]
: Localizer["{0} media files ({1})", backup.MediaFiles, backup.MediaBytes.GetFileSize(Localizer)])
@if (backup.MediaMissing > 0)
{
<span>; @Localizer["{0} missing", backup.MediaMissing]</span>
}
@if (!backup.Consistent)
{
<span>; @Localizer["not read at one instant"]</span>
}
</p>
@if (backup.RootsDeletedSince > 0)
{
<p class="text-xs">@Localizer["{0} logins were deleted since; a restore deletes them again.", backup.RootsDeletedSince]</p>
}
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@IsDefaultDisabled" OnClick="() => AskDownload(backup)" data-backup-download>
<i class="ion-md-download"></i> @Localizer["Download"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(IsDefaultDisabled || backup.NotRestorable is not null || backups.Running is not null || backups.Waiting is not null)"
OnClick="() => AskRestore(backup)" data-backup-restore>
<i class="ion-md-refresh"></i> @Localizer["Restore"]
</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || busy)" OnClick="() => Delete(backup)" data-backup-delete>
<i class="ion-md-trash"></i> @Localizer["Delete"]
</DButton>
</div>
</div>
}
}
</div>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(downloading is not null)" IsOpenChanged="open => { if (!open) CloseDownload(); }"
CloseText="@Localizer["Close"]" data-backup-download-dialog>
<Heading>@Localizer["Download a backup"]</Heading>
<ChildContent>
@if (ticket is null)
{
<p class="text-sm">@Localizer["It holds every persona's private keys and private posts: keep it where only you can read it. Your password, to get a link:"]</p>
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<ErrorViewer AdditionalError="@dialogError" />
}
else
{
<p class="text-sm">@Localizer["The link works for ten minutes, for this backup only."]</p>
<a class="btn btn-primary btn-sm self-start" href="@ticket" data-backup-link>
<i class="ion-md-download"></i> @Localizer["Download {0}", ticketSize.GetFileSize(Localizer)]
</a>
}
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseDownload" data-backup-dialog-close>@Localizer["Close"]</DButton>
@if (ticket is null)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password))" OnClick="Download" data-backup-confirm>
@Localizer["Get the link"]
</DButton>
}
</Actions>
</DModal>
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="@(restoring is not null)" IsOpenChanged="open => { if (!open) CloseRestore(); }"
CloseText="@Localizer["Close"]" data-backup-restore-dialog>
<Heading>@Localizer["Restore the backup of {0}?", restoring?.CreatedAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]</Heading>
<ChildContent>
<div class="flex flex-col gap-2 text-sm">
<p>@Localizer["PrivaPub stops, restores this backup and starts again, in a few minutes. Everyone signs in again."]</p>
<p>@Localizer["What was made since is lost: posts, edits, follows asked for, settings. What protects is not: deletions, blocks, mutes, server blocks, reports, filters and reserved names made since stay, and followers stay as they are now. Logins, personas and groups made since are deleted, keeping their names."]</p>
@if (restoring?.RootsDeletedSince > 0)
{
<p>@Localizer["{0} logins were deleted since; a restore deletes them again.", restoring.RootsDeletedSince]</p>
}
<DInput TValue="string" type="password" @bind-Value="password" Immediate="true" autocomplete="current-password" maxlength="200"
placeholder="@Localizer["Password"]" data-backup-password />
<DInput TValue="string" @bind-Value="typedHost" Immediate="true" autocomplete="off" maxlength="253"
placeholder="@Localizer["Type {0} to confirm", backups.Host ?? ""]" data-backup-host />
<ErrorViewer AdditionalError="@dialogError" />
</div>
</ChildContent>
<Actions>
<DButton Size="DSize.Sm" OnClick="CloseRestore">@Localizer["Cancel"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Error"
Disabled="@(IsDefaultDisabled || busy || string.IsNullOrEmpty(password) || !string.Equals(typedHost?.Trim(), backups.Host, StringComparison.OrdinalIgnoreCase))"
OnClick="Restore" data-backup-confirm>
@Localizer["Restore"]
</DButton>
</Actions>
</DModal>
@code {
[Inject] AdminService AdminService { get; set; }
[Inject] ConfirmService Confirm { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] AppConfiguration Configuration { get; set; }
[Inject] IJSRuntime JS { get; set; }
const string UploadInput = "input[data-backup-upload]";
static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3);
ViewBackups backups = new();
bool busy;
string restarting;
ViewBackup downloading;
ViewBackup restoring;
string password;
string typedHost;
string ticket;
long ticketSize;
string dialogError;
string pickedName;
bool uploading;
long uploaded;
long uploadSize;
double uploadedPercent;
IJSObjectReference transfer;
DotNetObjectReference<BackupsPanel> self;
CancellationTokenSource polling;
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
backups = await AdminService.Backups() ?? backups;
if (backups.Running is not null || backups.Waiting is not null)
Poll();
}
// while something runs, the list is read again every few seconds; a restore ends the session, and with it this page
void Poll()
{
if (polling is not null)
return;
polling = new();
var token = polling.Token;
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(PollEvery);
while (await timer.WaitForNextTickAsync(token))
{
var read = await AdminService.Backups(token);
if (read is not null)
backups = read;
await InvokeAsync(StateHasChanged);
if (restarting is null && read is not null && read.Running is null && read.Waiting is null)
break;
}
polling = null;
}, token);
}
static DColor KindColor(string kind) => kind switch
{
"nightly" => DColor.Neutral,
"manual" => DColor.Primary,
"pre-deploy" => DColor.Info,
"pre-restore" => DColor.Warning,
"uploaded" => DColor.Accent,
_ => DColor.Neutral
};
string KindName(string kind) => kind switch
{
"nightly" => Localizer["Nightly"],
"manual" => Localizer["Made by hand"],
"pre-deploy" => Localizer["Before a deploy"],
"pre-restore" => Localizer["Before a restore"],
"uploaded" => Localizer["Uploaded"],
_ => kind
};
async Task BackUp()
{
busy = true;
var result = await AdminService.BackUp();
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
backups.Running = "backup";
Toast.AddResponseSuccess(Localizer["The backup has started."]);
Poll();
}
async Task Delete(ViewBackup backup)
{
if (!await Confirm.Ask(Localizer["Delete this backup?"], Localizer["Its files are deleted; the server itself is untouched."], Localizer["Delete"], true))
return;
busy = true;
var result = await AdminService.DeleteBackup(backup.Id);
busy = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
backups.Backups = [.. backups.Backups.Where(b => b.Id != backup.Id)];
}
void AskDownload(ViewBackup backup)
{
(password, ticket, dialogError) = (null, null, null);
downloading = backup;
}
void CloseDownload() => (downloading, password, ticket, dialogError) = (null, null, null, null);
async Task Download()
{
busy = true;
var result = await AdminService.BackupTicket(downloading.Id, password);
busy = false;
if (result.IsInvalid)
{
dialogError = result.ErrorMessage;
return;
}
var granted = (ViewBackupTicket)result.Data;
ticket = new Uri(ApiBase, granted.Url).AbsoluteUri;
ticketSize = granted.Bytes;
password = null;
dialogError = null;
}
void AskRestore(ViewBackup backup)
{
(password, typedHost, dialogError) = (null, null, null);
restoring = backup;
}
void CloseRestore() => (restoring, password, typedHost, dialogError) = (null, null, null, null);
async Task Restore()
{
busy = true;
var asked = restoring;
var result = await AdminService.RestoreBackup(asked.Id, new RestoreBackupForm { Password = password, Host = typedHost?.Trim() });
busy = false;
if (result.IsInvalid)
{
dialogError = result.ErrorMessage;
return;
}
CloseRestore();
restarting = asked.Id;
Poll();
}
Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress);
void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null;
async Task Upload()
{
transfer ??= await JS.InvokeAsync<IJSObjectReference>("import", "./js/transfer.js");
self ??= DotNetObjectReference.Create(this);
var started = await AdminService.StartBackupUpload();
if (started.IsInvalid)
{
Toast.AddResponseError(started);
return;
}
var upload = (ViewBackupUpload)started.Data;
(uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0);
var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_AdminBackupUpload(upload.Id)).AbsoluteUri;
var sent = await transfer.InvokeAsync<UploadOutcome>("upload", UploadInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self);
if (!sent.Ok)
{
uploading = false;
await AdminService.CancelBackupUpload(upload.Id);
Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]);
return;
}
var finished = await AdminService.FinishBackupUpload(upload.Id);
uploading = false;
if (finished.IsInvalid)
{
Toast.AddResponseError(finished);
return;
}
var backup = (ViewBackup)finished.Data;
backups.Backups = [.. backups.Backups.Append(backup).OrderByDescending(b => b.CreatedAt)];
pickedName = null;
Toast.AddResponseSuccess(Localizer["The backup {0} was uploaded.", backup.Id]);
}
[JSInvokable]
public Task Progress(long received, long total)
{
(uploaded, uploadSize) = (received, total);
uploadedPercent = total == 0 ? 100 : 100.0 * received / total;
return InvokeAsync(StateHasChanged);
}
sealed class UploadOutcome
{
public bool Ok { get; set; }
public int Status { get; set; }
public string Error { get; set; }
}
public async ValueTask DisposeAsync()
{
polling?.Cancel();
self?.Dispose();
if (transfer is null)
return;
try
{
await transfer.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
}
}