448 lines
17 KiB
Plaintext
448 lines
17 KiB
Plaintext
@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)
|
||
|
|
{
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|