parent
19a63491fc
commit
4948dbc0d5
12 changed files with 831 additions and 188 deletions
|
|
@ -6,4 +6,10 @@ public class ImportDialogOptions
|
|||
{
|
||||
public string Hint { get; set; }
|
||||
public ManifestType? ManifestType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, the import dialog skips the upload stage and goes directly to record selection
|
||||
/// using this pre-uploaded archive's object key.
|
||||
/// </summary>
|
||||
public string? PreUploadedObjectKey { get; set; }
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
@using Hangfire;
|
||||
@using Hangfire;
|
||||
@using LANCommander.SDK.Enums
|
||||
@using LANCommander.Server.Jobs.Background;
|
||||
@using LANCommander.UI.Services
|
||||
@using Microsoft.Extensions.Options
|
||||
@inject ArchiveService ArchiveService
|
||||
@inject IOptions<Settings.Settings> Settings
|
||||
@inject IMessageService MessageService
|
||||
@inject ConfirmService ConfirmService
|
||||
@inject IJSRuntime JS
|
||||
@inject ILogger<ArchiveUploader> Logger
|
||||
|
||||
|
|
@ -22,10 +24,10 @@
|
|||
</Template>;
|
||||
}
|
||||
|
||||
<Modal Visible="@Visible" Title="Upload Archive" OnOk="Start" OnCancel="Cancel" Footer="@Footer" Closable="@(!Uploading)" MaskClosable="false">
|
||||
<Modal Visible="@Visible" Title="Upload Archive" OnOk="Start" OnCancel="Cancel" Footer="@Footer" Closable="true" MaskClosable="false">
|
||||
<Form Model="@Archive" Layout="@FormLayout.Vertical">
|
||||
<FormItem Label="Version">
|
||||
<Input @bind-Value="@context.Version" />
|
||||
<Input @bind-Value="@context.Version" />
|
||||
</FormItem>
|
||||
|
||||
<FormItem Label="Changelog">
|
||||
|
|
@ -37,7 +39,7 @@
|
|||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<ChunkUploader @ref="ChunkUploader" Accept=".zip" @bind-File="File" @bind-Status="Status" @bind-StorageLocationId="@(context.StorageLocation.Id)" OnUploadCompleted="OnUploadCompleted" OnUploadError="OnUploadError">
|
||||
<ChunkUploader @ref="ChunkUploader" Accept=".zip" @bind-File="File" @bind-Status="Status" @bind-StorageLocationId="@(context.StorageLocation.Id)">
|
||||
<Text>
|
||||
<p>Drag and Drop</p>
|
||||
<p>or</p>
|
||||
|
|
@ -66,7 +68,7 @@
|
|||
|
||||
Guid StorageLocationId;
|
||||
StorageLocation StorageLocation;
|
||||
|
||||
|
||||
bool Visible = false;
|
||||
bool Uploading = false;
|
||||
|
||||
|
|
@ -77,32 +79,109 @@
|
|||
private async Task Start()
|
||||
{
|
||||
Uploading = true;
|
||||
try
|
||||
|
||||
// Capture values NOW (at the moment user clicks Upload)
|
||||
// so the callback has correct data regardless of component lifecycle.
|
||||
var gameId = Archive.GameId;
|
||||
var redistributableId = Archive.RedistributableId;
|
||||
var toolId = Archive.ToolId;
|
||||
var version = Archive.Version;
|
||||
var changelog = Archive.Changelog;
|
||||
|
||||
await ChunkUploader.Start(async (string objectKeyStr) =>
|
||||
{
|
||||
await ChunkUploader.Start();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Uploading = false;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!Guid.TryParse(objectKeyStr, out var objectKey))
|
||||
{
|
||||
Logger.LogError("Archive failed to upload - invalid object key");
|
||||
return;
|
||||
}
|
||||
|
||||
var uploadedArchive = await ArchiveService.FirstOrDefaultAsync(a => a.ObjectKey == objectKey.ToString());
|
||||
|
||||
if (uploadedArchive == null)
|
||||
{
|
||||
Logger.LogError("Archive record not found for object key {ObjectKey}", objectKeyStr);
|
||||
return;
|
||||
}
|
||||
|
||||
uploadedArchive.GameId = gameId;
|
||||
uploadedArchive.RedistributableId = redistributableId;
|
||||
uploadedArchive.ToolId = toolId;
|
||||
uploadedArchive.Version = version;
|
||||
uploadedArchive.Changelog = changelog;
|
||||
uploadedArchive.CompressedSize = await ArchiveService.GetCompressedSizeAsync(uploadedArchive);
|
||||
uploadedArchive.UncompressedSize = await ArchiveService.GetUncompressedSizeAsync(uploadedArchive);
|
||||
|
||||
await ArchiveService.UpdateAsync(uploadedArchive);
|
||||
|
||||
if (Settings.Value.Server.Archives.EnablePatching)
|
||||
{
|
||||
Archive? lastArchive = null;
|
||||
|
||||
if (gameId != Guid.Empty)
|
||||
lastArchive = await ArchiveService.GetLatestArchiveAsync(a => a.Id != uploadedArchive.Id && a.GameId == gameId);
|
||||
else if (redistributableId != Guid.Empty)
|
||||
lastArchive = await ArchiveService.GetLatestArchiveAsync(a => a.Id != uploadedArchive.Id && a.RedistributableId == redistributableId);
|
||||
else if (toolId != Guid.Empty)
|
||||
lastArchive = await ArchiveService.GetLatestArchiveAsync(a => a.Id != uploadedArchive.Id && a.ToolId == toolId);
|
||||
|
||||
if (lastArchive != null)
|
||||
BackgroundJob.Enqueue<PatchArchiveBackgroundJob>(x => x.Execute(lastArchive.Id, uploadedArchive.Id));
|
||||
}
|
||||
|
||||
if (OnArchiveUploaded.HasDelegate)
|
||||
await OnArchiveUploaded.InvokeAsync(uploadedArchive.Id);
|
||||
|
||||
Uploading = false;
|
||||
Visible = false;
|
||||
|
||||
try { await InvokeAsync(StateHasChanged); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to process uploaded archive");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task Clear()
|
||||
{
|
||||
await ChunkUploader.Clear();
|
||||
if (ChunkUploader != null)
|
||||
await ChunkUploader.Clear();
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
{
|
||||
await ChunkUploader.Clear();
|
||||
Visible = false;
|
||||
if (Uploading && ChunkUploader?.IsUploading == true)
|
||||
{
|
||||
var result = await ConfirmService.Show(
|
||||
"The upload will continue in the background. You can track its progress in the sidebar.",
|
||||
"Continue in Background?",
|
||||
ConfirmButtons.OKCancel,
|
||||
ConfirmIcon.Info);
|
||||
|
||||
if (result == ConfirmResult.OK)
|
||||
{
|
||||
Visible = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ChunkUploader != null)
|
||||
await ChunkUploader.Clear();
|
||||
|
||||
Visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Open(Guid? archiveId = null)
|
||||
{
|
||||
if (archiveId.HasValue && archiveId != Guid.Empty)
|
||||
{
|
||||
Archive = await ArchiveService.GetAsync(archiveId.Value);
|
||||
Archive = await ArchiveService.GetAsync(archiveId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -116,66 +195,12 @@
|
|||
Archive.ToolId = ToolId;
|
||||
}
|
||||
|
||||
Uploading = false;
|
||||
Visible = true;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public async Task OnUploadCompleted(string data)
|
||||
{
|
||||
Uploading = false;
|
||||
|
||||
if (Guid.TryParse(data, out var objectKey))
|
||||
{
|
||||
var uploadedArchive = await ArchiveService.FirstOrDefaultAsync(a => a.ObjectKey == objectKey.ToString());
|
||||
|
||||
uploadedArchive.GameId = Archive.GameId;
|
||||
uploadedArchive.RedistributableId = Archive.RedistributableId;
|
||||
uploadedArchive.ToolId = Archive.ToolId;
|
||||
uploadedArchive.Version = Archive.Version;
|
||||
uploadedArchive.Changelog = Archive.Changelog;
|
||||
uploadedArchive.CompressedSize = await ArchiveService.GetCompressedSizeAsync(uploadedArchive);
|
||||
uploadedArchive.UncompressedSize = await ArchiveService.GetUncompressedSizeAsync(uploadedArchive);
|
||||
|
||||
await ArchiveService.UpdateAsync(uploadedArchive);
|
||||
|
||||
Visible = false;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
Archive? lastArchive = null;
|
||||
|
||||
if (Settings.Value.Server.Archives.EnablePatching)
|
||||
{
|
||||
if (Archive.GameId != Guid.Empty)
|
||||
lastArchive = await ArchiveService.GetLatestArchiveAsync(a => a.Id != Archive.Id && a.GameId == Archive.GameId);
|
||||
else if (Archive.RedistributableId != Guid.Empty)
|
||||
lastArchive = await ArchiveService.GetLatestArchiveAsync(a => a.Id != Archive.Id && a.RedistributableId == Archive.RedistributableId);
|
||||
else if (Archive.ToolId != Guid.Empty)
|
||||
lastArchive = await ArchiveService.GetLatestArchiveAsync(a => a.Id != Archive.Id && a.ToolId == Archive.ToolId);
|
||||
|
||||
if (lastArchive != null && Settings.Value.Server.Archives.EnablePatching)
|
||||
BackgroundJob.Enqueue<PatchArchiveBackgroundJob>(x => x.Execute(lastArchive.Id, Archive.Id));
|
||||
}
|
||||
|
||||
if (OnArchiveUploaded.HasDelegate)
|
||||
await OnArchiveUploaded.InvokeAsync(uploadedArchive.Id);
|
||||
|
||||
MessageService.Success("Archive uploaded!");
|
||||
}
|
||||
else
|
||||
{
|
||||
Visible = false;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
MessageService.Error("Archive failed to upload!");
|
||||
Logger.LogError("Archive failed to upload!");
|
||||
}
|
||||
|
||||
await ChunkUploader.Clear();
|
||||
}
|
||||
|
||||
public async Task OnUploadError(string message)
|
||||
{
|
||||
Uploading = false;
|
||||
|
|
@ -193,7 +218,34 @@
|
|||
{
|
||||
var objectKey = await ArchiveService.CopyFromLocalFileAsync(path, Archive.StorageLocation.Id);
|
||||
|
||||
await OnUploadCompleted(objectKey.ToString());
|
||||
// For local files, run the completion logic directly.
|
||||
var gameId = Archive.GameId;
|
||||
var redistributableId = Archive.RedistributableId;
|
||||
var toolId = Archive.ToolId;
|
||||
var version = Archive.Version;
|
||||
var changelog = Archive.Changelog;
|
||||
|
||||
var uploadedArchive = await ArchiveService.FirstOrDefaultAsync(a => a.ObjectKey == objectKey.ToString());
|
||||
|
||||
if (uploadedArchive != null)
|
||||
{
|
||||
uploadedArchive.GameId = gameId;
|
||||
uploadedArchive.RedistributableId = redistributableId;
|
||||
uploadedArchive.ToolId = toolId;
|
||||
uploadedArchive.Version = version;
|
||||
uploadedArchive.Changelog = changelog;
|
||||
uploadedArchive.CompressedSize = await ArchiveService.GetCompressedSizeAsync(uploadedArchive);
|
||||
uploadedArchive.UncompressedSize = await ArchiveService.GetUncompressedSizeAsync(uploadedArchive);
|
||||
|
||||
await ArchiveService.UpdateAsync(uploadedArchive);
|
||||
|
||||
if (OnArchiveUploaded.HasDelegate)
|
||||
await OnArchiveUploaded.InvokeAsync(uploadedArchive.Id);
|
||||
}
|
||||
|
||||
Visible = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
MessageService.Success("Archive uploaded!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
@using LANCommander.SDK.Enums
|
||||
@using LANCommander.SDK.Enums
|
||||
@using LANCommander.Server.ImportExport
|
||||
@using LANCommander.Server.ImportExport.Factories
|
||||
@using LANCommander.Server.ImportExport.Models
|
||||
@using LANCommander.UI.Services
|
||||
@inherits FeedbackComponent<ImportDialogOptions>
|
||||
@inject ArchiveService ArchiveService
|
||||
@inject StorageLocationService StorageLocationService
|
||||
@inject ImportContextFactory ImportContextFactory
|
||||
@inject UploadTracker UploadTracker
|
||||
@inject ConfirmService ConfirmService
|
||||
@inject IMessageService MessageService
|
||||
@inject ILogger<ImportUploadDialog> Logger
|
||||
|
||||
|
|
@ -18,7 +21,7 @@
|
|||
@bind-File="File"
|
||||
@bind-Status="Status"
|
||||
StorageLocationId="StorageLocation.Id"
|
||||
OnUploadCompleted="OnUploadCompleted"
|
||||
UploadType="UploadType.Import"
|
||||
OnUploadError="OnUploadError">
|
||||
<Text>
|
||||
<p>Drag and Drop</p>
|
||||
|
|
@ -32,7 +35,7 @@
|
|||
|
||||
<StorageLocationSelector @bind-Value="StorageLocation" Type="StorageLocationType.Archive"/>
|
||||
</Flex>
|
||||
|
||||
|
||||
<Flex Justify="FlexJustify.End" Gap="FlexGap.Small" Style="margin-top: 16px;">
|
||||
<FilePickerButton
|
||||
EntrySelectable="@(entry => !String.IsNullOrWhiteSpace(entry.Name) && entry.Name.ToLower().EndsWith(".lcx"))"
|
||||
|
|
@ -55,14 +58,14 @@ else if (_stage == ImportStage.RecordSelection)
|
|||
@foreach (var group in ImportItems.GroupBy(i => i.Type))
|
||||
{
|
||||
var size = group.Sum(i => i.Size);
|
||||
|
||||
|
||||
<TreeNode Title="@($"{group.Key} ({group.Count()})")">
|
||||
<TitleTemplate>
|
||||
<GridRow>
|
||||
<GridCol Flex=@("auto")>
|
||||
@group.Key.GetDisplayName() (@group.Count())
|
||||
</GridCol>
|
||||
|
||||
|
||||
@if (size > 0)
|
||||
{
|
||||
<GridCol Flex=@("none")>
|
||||
|
|
@ -105,12 +108,12 @@ else if (_stage == ImportStage.Importing)
|
|||
{
|
||||
<Flex Align="FlexAlign.Center" Direction="FlexDirection.Vertical" Gap="FlexGap.Middle">
|
||||
<Progress Type="ProgressType.Circle" Percent="_importProgress" />
|
||||
|
||||
|
||||
@if (!String.IsNullOrWhiteSpace(_currentImportItemName))
|
||||
{
|
||||
<Text>Importing @_currentImportItemName</Text>
|
||||
}
|
||||
|
||||
|
||||
<Flex Gap="FlexGap.Small" Direction="FlexDirection.Vertical">
|
||||
@foreach (var error in _errors)
|
||||
{
|
||||
|
|
@ -136,7 +139,7 @@ else if (_stage == ImportStage.Failed)
|
|||
{
|
||||
<Alert Type="AlertType.Error" Message="@error" />
|
||||
}
|
||||
|
||||
|
||||
<Button Type="ButtonType.Primary" OnClick="CloseFeedbackAsync">Close</Button>
|
||||
</Flex>
|
||||
</Extra>
|
||||
|
|
@ -160,12 +163,13 @@ else if (_stage == ImportStage.Failed)
|
|||
string _uploadObjectKey;
|
||||
|
||||
IEnumerable<IImportItemInfo> ImportItems = new List<IImportItemInfo>();
|
||||
|
||||
|
||||
string RootPath = Path.GetPathRoot(Directory.GetCurrentDirectory());
|
||||
|
||||
bool IsValid = false;
|
||||
string Filename;
|
||||
string Status = "";
|
||||
bool _uploading = false;
|
||||
|
||||
ImportStage _stage = ImportStage.Upload;
|
||||
string[] _selectedKeys;
|
||||
|
|
@ -179,24 +183,30 @@ else if (_stage == ImportStage.Failed)
|
|||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
ImportContext = ImportContextFactory.Create();
|
||||
|
||||
|
||||
ImportContext.OnImportStarted.EventRaised += ImportStarted;
|
||||
ImportContext.OnImportComplete.EventRaised += ImportComplete;
|
||||
ImportContext.OnImportStatusUpdate.EventRaised += ImportStatusUpdate;
|
||||
ImportContext.OnImportError.EventRaised += ImportError;
|
||||
|
||||
StorageLocation = await StorageLocationService.FirstAsync(l => l.Default && l.Type == StorageLocationType.Archive);
|
||||
|
||||
// If a pre-uploaded object key was provided, skip the upload stage.
|
||||
if (!string.IsNullOrWhiteSpace(Options.PreUploadedObjectKey))
|
||||
{
|
||||
await OnUploadCompleted(Options.PreUploadedObjectKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImportStarted(ImportStatusUpdate status)
|
||||
{
|
||||
_stage = ImportStage.Importing;
|
||||
_importProgress = -1;
|
||||
|
||||
|
||||
await Task.Yield();
|
||||
await InvokeStateHasChangedAsync();
|
||||
}
|
||||
|
||||
|
||||
private async Task ImportComplete(ImportStatusUpdate status)
|
||||
{
|
||||
_stage = ImportStage.Complete;
|
||||
|
|
@ -209,7 +219,7 @@ else if (_stage == ImportStage.Failed)
|
|||
await Task.Yield();
|
||||
await InvokeStateHasChangedAsync();
|
||||
}
|
||||
|
||||
|
||||
private async Task ImportStatusUpdate(ImportStatusUpdate status) => await UpdateProgress(status);
|
||||
|
||||
private async Task ImportError(ImportStatusUpdate status)
|
||||
|
|
@ -220,14 +230,14 @@ else if (_stage == ImportStage.Failed)
|
|||
await Task.Yield();
|
||||
await InvokeStateHasChangedAsync();
|
||||
}
|
||||
|
||||
|
||||
private async Task UpdateProgress(ImportStatusUpdate? status)
|
||||
{
|
||||
if (status != null)
|
||||
{
|
||||
_importProgress = status.Total > 0 ? (int)Math.Floor(status.Index / (decimal)status.Total * 100) : 100;
|
||||
_currentImportItemName = $"{status.CurrentItem?.Type.GetDisplayName()}: {status.CurrentItem?.Name}";
|
||||
|
||||
|
||||
await Task.Yield();
|
||||
await InvokeStateHasChangedAsync();
|
||||
}
|
||||
|
|
@ -235,7 +245,20 @@ else if (_stage == ImportStage.Failed)
|
|||
|
||||
async Task Upload()
|
||||
{
|
||||
await ChunkUploader.Start();
|
||||
_uploading = true;
|
||||
await ChunkUploader.Start(async (objectKey) =>
|
||||
{
|
||||
// When the dialog is still open, transition to RecordSelection.
|
||||
// When it was closed (background upload), the UploadIndicator notification handles it.
|
||||
try
|
||||
{
|
||||
await OnUploadCompleted(objectKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Component may be disposed if dialog was closed; ignore.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async Task Import()
|
||||
|
|
@ -246,7 +269,7 @@ else if (_stage == ImportStage.Failed)
|
|||
.Select(k => Guid.TryParse(k, out var id) ? (Guid?)id : null)
|
||||
.Where(id => id.HasValue)
|
||||
.Select(g => g!.Value);
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
await ImportContext.PrepareImportQueueAsync(selectedGuids, StorageLocation.Id);
|
||||
|
|
@ -263,17 +286,43 @@ else if (_stage == ImportStage.Failed)
|
|||
|
||||
async Task Clear()
|
||||
{
|
||||
await ChunkUploader.Clear();
|
||||
if (ChunkUploader != null)
|
||||
await ChunkUploader.Clear();
|
||||
}
|
||||
|
||||
async Task Cancel()
|
||||
{
|
||||
await ChunkUploader.Clear();
|
||||
await CloseFeedbackAsync();
|
||||
if (_uploading && ChunkUploader?.IsUploading == true)
|
||||
{
|
||||
var result = await ConfirmService.Show(
|
||||
"The upload will continue in the background. You will be notified when it's ready for import.",
|
||||
"Continue in Background?",
|
||||
ConfirmButtons.OKCancel,
|
||||
ConfirmIcon.Info);
|
||||
|
||||
if (result == ConfirmResult.OK)
|
||||
{
|
||||
await CloseFeedbackAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ChunkUploader != null)
|
||||
await ChunkUploader.Clear();
|
||||
|
||||
await CloseFeedbackAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task OnFeedbackCancelAsync(ModalClosingEventArgs args)
|
||||
{
|
||||
// If uploading in background, don't clean up - let it continue.
|
||||
if (_uploading && ChunkUploader?.IsUploading == true)
|
||||
{
|
||||
await base.OnFeedbackCancelAsync(args);
|
||||
return;
|
||||
}
|
||||
|
||||
await Clear();
|
||||
|
||||
ImportContext.Dispose();
|
||||
|
|
@ -284,7 +333,8 @@ else if (_stage == ImportStage.Failed)
|
|||
|
||||
public override async Task OnFeedbackOkAsync(ModalClosingEventArgs args)
|
||||
{
|
||||
await ChunkUploader.Clear();
|
||||
if (ChunkUploader != null)
|
||||
await ChunkUploader.Clear();
|
||||
|
||||
ImportContext.Dispose();
|
||||
await CleanupUploadArchiveAsync();
|
||||
|
|
@ -305,8 +355,9 @@ else if (_stage == ImportStage.Failed)
|
|||
ImportItems = await ImportContext.InitializeImportAsync(archivePath, Options.ManifestType);
|
||||
|
||||
_selectedKeys = ImportItems.Select(i => i.Key).ToArray();
|
||||
|
||||
|
||||
_stage = ImportStage.RecordSelection;
|
||||
_uploading = false;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
|
@ -326,12 +377,12 @@ else if (_stage == ImportStage.Failed)
|
|||
await (base.OkCancelRef?.OnCancel?.Invoke() ?? Task.CompletedTask);
|
||||
await CloseFeedbackAsync();
|
||||
}
|
||||
|
||||
await Clear();
|
||||
}
|
||||
|
||||
public async Task OnUploadError(string message)
|
||||
{
|
||||
_uploading = false;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
MessageService.Error("An error occurred while trying to import");
|
||||
|
|
@ -365,9 +416,9 @@ else if (_stage == ImportStage.Failed)
|
|||
try
|
||||
{
|
||||
ImportItems = await ImportContext.InitializeImportAsync(path, Options.ManifestType);
|
||||
|
||||
|
||||
_selectedKeys = ImportItems.Select(i => i.Key).ToArray();
|
||||
|
||||
|
||||
_stage = ImportStage.RecordSelection;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
|
@ -378,4 +429,4 @@ else if (_stage == ImportStage.Failed)
|
|||
MessageService.Error("An unknown error occurred while trying to import");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
<Flex Vertical Style="height: 100%;">
|
||||
<div class="logo"><img src="/static/logo-cut.svg"></div>
|
||||
|
||||
<Flex Vertical Gap="FlexGap.Small" Style="flex-grow: 1" Justify="FlexJustify.SpaceBetween">
|
||||
<Flex Direction="FlexDirection.Vertical" Gap="FlexGap.Small" Style="flex-grow: 1" Justify="FlexJustify.SpaceBetween">
|
||||
<MainMenu />
|
||||
|
||||
<Social />
|
||||
<Flex Direction="FlexDirection.Vertical">
|
||||
<UploadIndicator />
|
||||
<Social />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Sider>
|
||||
203
LANCommander.Server/UI/Components/UploadIndicator.razor
Normal file
203
LANCommander.Server/UI/Components/UploadIndicator.razor
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
@using LANCommander.Server.Models
|
||||
@using LANCommander.UI.Services
|
||||
@inject UploadTracker UploadTracker
|
||||
@inject IMessageService MessageService
|
||||
@inject INotificationService NotificationService
|
||||
@inject ModalService ModalService
|
||||
@implements IDisposable
|
||||
|
||||
@if (UploadTracker.ActiveUploads.Any())
|
||||
{
|
||||
<Popover Trigger="@(new[] { Trigger.Click })" Placement="Placement.RightTop" OverlayClassName="upload-indicator-popover" @bind-Visible="_popoverVisible">
|
||||
<ContentTemplate>
|
||||
<div style="min-width: 300px; max-width: 400px;">
|
||||
@foreach (var upload in UploadTracker.ActiveUploads.Values.ToList())
|
||||
{
|
||||
<div style="margin-bottom: 12px;">
|
||||
<Flex Justify="FlexJustify.SpaceBetween" Align="FlexAlign.Center">
|
||||
<span style="font-size: 13px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
@upload.FileName
|
||||
</span>
|
||||
<Flex Gap="FlexGap.Small">
|
||||
@if (upload.Status == UploadStatus.Complete && upload.Type == UploadType.Import && !string.IsNullOrEmpty(upload.CompletedObjectKey))
|
||||
{
|
||||
<Button Type="ButtonType.Text" Size="ButtonSize.Small" OnClick="()=> OpenImportDialogFromPopup(upload)" Icon="@IconType.Outline.Import" />
|
||||
}
|
||||
@if (upload.Status == UploadStatus.Uploading)
|
||||
{
|
||||
<Tooltip Title="Cancel Upload">
|
||||
<Button Type="ButtonType.Text" Danger Size="ButtonSize.Small" Icon="@IconType.Outline.Close" OnClick="() => CancelUpload(upload.UploadId)" />
|
||||
</Tooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Tooltip Title="Dismiss">
|
||||
<Button Type="ButtonType.Text" Size="ButtonSize.Small" Icon="@IconType.Outline.Close" OnClick="() => DismissUpload(upload.UploadId)" />
|
||||
</Tooltip>
|
||||
}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@if (upload.Status == UploadStatus.Uploading)
|
||||
{
|
||||
<Progress Percent="upload.Percent" Size="ProgressSize.Small" Status="ProgressStatus.Active" />
|
||||
@if (upload.Speed > 0)
|
||||
{
|
||||
<span style="font-size: 11px; color: rgba(255,255,255,0.45);">
|
||||
@FormatSpeed(upload.Speed)
|
||||
</span>
|
||||
}
|
||||
}
|
||||
else if (upload.Status == UploadStatus.Complete)
|
||||
{
|
||||
<Progress Percent="100" Size="ProgressSize.Small" Status="ProgressStatus.Success" />
|
||||
}
|
||||
else if (upload.Status == UploadStatus.Error)
|
||||
{
|
||||
<Progress Percent="upload.Percent" Size="ProgressSize.Small" Status="ProgressStatus.Exception" />
|
||||
<span style="font-size: 11px; color: #ff4d4f;">
|
||||
@(upload.ErrorMessage ?? "Upload failed")
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ContentTemplate>
|
||||
<ChildContent>
|
||||
<Menu Mode="MenuMode.Inline" Style="border-right: 0;">
|
||||
<MenuItem>
|
||||
<Badge Count="@ActiveCount" Size="BadgeSize.Small" Offset="(8, -4)">
|
||||
<Icon Type="@IconType.Outline.CloudUpload"/>
|
||||
<span>Uploads</span>
|
||||
</Badge>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</ChildContent>
|
||||
</Popover>
|
||||
}
|
||||
|
||||
@code {
|
||||
bool _popoverVisible;
|
||||
NotificationRef? _activeNotificationRef;
|
||||
|
||||
int ActiveCount => UploadTracker.ActiveUploads.Values.Count(u => u.Status == UploadStatus.Uploading);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
UploadTracker.OnStateChanged += HandleStateChanged;
|
||||
UploadTracker.OnUploadCompleted += HandleUploadCompleted;
|
||||
}
|
||||
|
||||
private void HandleStateChanged()
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleUploadCompleted(BackgroundUploadInfo info)
|
||||
{
|
||||
if (info.Type == UploadType.Import && !string.IsNullOrEmpty(info.CompletedObjectKey))
|
||||
{
|
||||
var objectKey = info.CompletedObjectKey;
|
||||
|
||||
var notificationKey = $"import-ready-{objectKey}";
|
||||
|
||||
var config = new NotificationConfig
|
||||
{
|
||||
Key = notificationKey,
|
||||
Message = "Import Ready",
|
||||
Description = $"\"{info.FileName}\" has finished uploading and is ready to import.",
|
||||
Duration = 0,
|
||||
NotificationType = NotificationType.Success,
|
||||
Btn = CreateImportNotificationButton(objectKey, notificationKey),
|
||||
};
|
||||
|
||||
_activeNotificationRef = await NotificationService.Open(config);
|
||||
}
|
||||
else if (info.Type == UploadType.Archive)
|
||||
{
|
||||
MessageService.Success($"\"{info.FileName}\" uploaded successfully!");
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment CreateImportNotificationButton(string objectKey, string notificationKey) => builder =>
|
||||
{
|
||||
builder.OpenComponent<Button>(0);
|
||||
builder.AddAttribute(1, "Type", ButtonType.Primary);
|
||||
builder.AddAttribute(2, "Size", ButtonSize.Small);
|
||||
builder.AddAttribute(3, "OnClick", EventCallback.Factory.Create<Microsoft.AspNetCore.Components.Web.MouseEventArgs>(this, () => OpenImportDialogFromNotification(objectKey, notificationKey)));
|
||||
builder.AddAttribute(4, "ChildContent", (RenderFragment)(b => b.AddContent(0, "Continue Import")));
|
||||
builder.CloseComponent();
|
||||
};
|
||||
|
||||
private async Task OpenImportDialogFromNotification(string objectKey, string notificationKey)
|
||||
{
|
||||
// Close the notification by key
|
||||
await NotificationService.Close(notificationKey);
|
||||
_activeNotificationRef = null;
|
||||
|
||||
OpenImportDialog(objectKey);
|
||||
}
|
||||
|
||||
private void OpenImportDialogFromPopup(BackgroundUploadInfo info)
|
||||
{
|
||||
if (string.IsNullOrEmpty(info.CompletedObjectKey))
|
||||
return;
|
||||
|
||||
var objectKey = info.CompletedObjectKey;
|
||||
|
||||
// Remove the completed upload from the list
|
||||
UploadTracker.RemoveUpload(info.UploadId);
|
||||
|
||||
OpenImportDialog(objectKey);
|
||||
}
|
||||
|
||||
private void OpenImportDialog(string objectKey)
|
||||
{
|
||||
var options = new ImportDialogOptions
|
||||
{
|
||||
Hint = "Select items to import",
|
||||
PreUploadedObjectKey = objectKey,
|
||||
};
|
||||
|
||||
var modalOptions = new ModalOptions
|
||||
{
|
||||
Title = "Import",
|
||||
DestroyOnClose = true,
|
||||
MaskClosable = false,
|
||||
Footer = null,
|
||||
};
|
||||
|
||||
ModalService.CreateModal<ImportUploadDialog, ImportDialogOptions>(modalOptions, options);
|
||||
}
|
||||
|
||||
private async Task CancelUpload(string uploadId)
|
||||
{
|
||||
await UploadTracker.CancelUploadAsync(uploadId);
|
||||
}
|
||||
|
||||
private void DismissUpload(string uploadId)
|
||||
{
|
||||
UploadTracker.RemoveUpload(uploadId);
|
||||
}
|
||||
|
||||
private string FormatSpeed(double bytesPerSecond)
|
||||
{
|
||||
string[] units = { "B/s", "KB/s", "MB/s", "GB/s" };
|
||||
int unitIndex = 0;
|
||||
double speed = bytesPerSecond;
|
||||
|
||||
while (speed >= 1024 && unitIndex < units.Length - 1)
|
||||
{
|
||||
speed /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return $"{speed:F1} {units[unitIndex]}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UploadTracker.OnStateChanged -= HandleStateChanged;
|
||||
UploadTracker.OnUploadCompleted -= HandleUploadCompleted;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
@using LANCommander.Server.Services.Abstractions
|
||||
@using LANCommander.Server.Services.Abstractions
|
||||
@using LANCommander.UI.Services
|
||||
@inherits LayoutComponentBase
|
||||
@inject IVersionProvider VersionProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject UploadTracker UploadTracker
|
||||
|
||||
<Layout Class="layout">
|
||||
<Sidebar />
|
||||
|
|
@ -24,4 +26,14 @@
|
|||
LANCommander v@(VersionProvider.GetCurrentVersion().WithoutMetadata().ToString())
|
||||
</Footer>
|
||||
</Flex>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
@code {
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await UploadTracker.InitializeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
@using LANCommander.UI.Providers
|
||||
@using LANCommander.UI.Services
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.JSInterop
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigator
|
||||
@inject ScriptProvider ScriptProvider
|
||||
@inject UploadTracker UploadTracker
|
||||
@inject IMessageService MessageService
|
||||
@implements IAsyncDisposable
|
||||
@implements IDisposable
|
||||
@namespace LANCommander.UI.Components
|
||||
|
||||
<div class="ant-upload ant-upload-select-text ant-upload-drag ant-upload-select chunk-uploader" data-status="@CurrentProgressStatus">
|
||||
|
|
@ -15,7 +12,7 @@
|
|||
|
||||
<label tabindex="0" class="ant-upload" style="display: grid;" for="@($"ChunkFileInput-{_uploaderId}")" role="button">
|
||||
<InputFile @ref="_fileInput" @key="@_uploaderId" id="@($"ChunkFileInput-{_uploaderId}")" OnChange="OnFileSelected" accept="@Accept" style="position: absolute; width: 100%; height: 100%; opacity: 0; top: 0;left: 0; z-index:2;" />
|
||||
|
||||
|
||||
<div class="ant-upload-drag-container">
|
||||
<p class="ant-upload-drag-icon">
|
||||
<Icon Type="@IconType.Outline.Upload" />
|
||||
|
|
@ -68,18 +65,21 @@
|
|||
[Parameter] public Guid StorageLocationId { get; set; }
|
||||
[Parameter] public EventCallback<Guid> StorageLocationIdChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of upload for tracking purposes.
|
||||
/// </summary>
|
||||
[Parameter] public UploadType UploadType { get; set; } = UploadType.Archive;
|
||||
|
||||
readonly Guid _uploaderId = Guid.NewGuid();
|
||||
|
||||
InputFile? _fileInput;
|
||||
string? _currentUploadId;
|
||||
|
||||
int _progress = 0;
|
||||
bool _uploading = false;
|
||||
bool _finished = false;
|
||||
bool _clearInput = false;
|
||||
double _speed = 0;
|
||||
|
||||
IJSObjectReference? _uploaderInterop;
|
||||
|
||||
ProgressStatus CurrentProgressStatus
|
||||
{
|
||||
get
|
||||
|
|
@ -98,56 +98,78 @@
|
|||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
HttpClient.BaseAddress = new Uri(Navigator.BaseUri);
|
||||
UploadTracker.OnStateChanged += HandleUploadStateChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
/// <summary>
|
||||
/// Starts the upload. The onCompleted callback is invoked when the upload finishes
|
||||
/// and survives component disposal.
|
||||
/// </summary>
|
||||
public async Task Start(Func<string, Task>? onCompleted = null)
|
||||
{
|
||||
if (firstRender)
|
||||
_uploaderInterop ??= await ScriptProvider.ImportModuleAsync<ChunkUploader>();
|
||||
}
|
||||
|
||||
public async Task Start()
|
||||
{
|
||||
if (_uploaderInterop is null)
|
||||
return;
|
||||
|
||||
_uploading = true;
|
||||
|
||||
await ChangeStatus("Uploading");
|
||||
|
||||
ushort i = 0;
|
||||
var fileName = File?.Name ?? "Unknown";
|
||||
|
||||
while (i < 20)
|
||||
{
|
||||
if (_fileInput != null)
|
||||
{
|
||||
if (!String.IsNullOrWhiteSpace(ObjectKey) && ObjectKey != Guid.Empty.ToString())
|
||||
await _uploaderInterop.InvokeVoidAsync("Init", $"ChunkFileInput-{_uploaderId}", StorageLocationId, ObjectKey);
|
||||
else
|
||||
await _uploaderInterop.InvokeVoidAsync("Init", $"ChunkFileInput-{_uploaderId}", StorageLocationId, "");
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
i++;
|
||||
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
var dotNetReference = DotNetObjectReference.Create(this);
|
||||
|
||||
await _uploaderInterop.InvokeVoidAsync("Upload", dotNetReference);
|
||||
_currentUploadId = await UploadTracker.StartUploadAsync(
|
||||
$"ChunkFileInput-{_uploaderId}",
|
||||
StorageLocationId,
|
||||
fileName,
|
||||
UploadType,
|
||||
(!string.IsNullOrWhiteSpace(ObjectKey) && ObjectKey != Guid.Empty.ToString()) ? ObjectKey : null,
|
||||
onCompleted);
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void HandleUploadStateChanged()
|
||||
{
|
||||
if (_currentUploadId != null && UploadTracker.ActiveUploads.TryGetValue(_currentUploadId, out var info))
|
||||
{
|
||||
_progress = info.Percent;
|
||||
_speed = info.Speed;
|
||||
|
||||
if (info.Status == UploadStatus.Complete)
|
||||
{
|
||||
_uploading = false;
|
||||
_finished = true;
|
||||
_ = ChangeStatus("Complete");
|
||||
}
|
||||
else if (info.Status == UploadStatus.Error)
|
||||
{
|
||||
_uploading = false;
|
||||
_ = ChangeStatus("");
|
||||
try
|
||||
{
|
||||
if (OnUploadError.HasDelegate)
|
||||
_ = OnUploadError.InvokeAsync(info.ErrorMessage ?? "Upload failed");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Component may be disposed; ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Component may be disposed; ignore.
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Clear()
|
||||
{
|
||||
if (_uploaderInterop is null)
|
||||
return;
|
||||
|
||||
await _uploaderInterop.InvokeVoidAsync("Clear");
|
||||
if (_currentUploadId != null)
|
||||
{
|
||||
await UploadTracker.CancelUploadAsync(_currentUploadId);
|
||||
_currentUploadId = null;
|
||||
}
|
||||
|
||||
File = null;
|
||||
|
||||
|
|
@ -194,44 +216,12 @@
|
|||
await StatusChanged.InvokeAsync(Status);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task JSOnUploadComplete(string objectKey)
|
||||
public string FileInputId => $"ChunkFileInput-{_uploaderId}";
|
||||
|
||||
public bool IsUploading => _uploading;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_uploading = false;
|
||||
_finished = true;
|
||||
await ChangeStatus("Complete");
|
||||
|
||||
if (OnUploadCompleted.HasDelegate)
|
||||
await OnUploadCompleted.InvokeAsync(objectKey);
|
||||
UploadTracker.OnStateChanged -= HandleUploadStateChanged;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task JSOnUploadError(string message)
|
||||
{
|
||||
if (OnUploadError.HasDelegate)
|
||||
await OnUploadError.InvokeAsync(message);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_fileInput is not null)
|
||||
await CastAndDispose(_fileInput);
|
||||
|
||||
if (_uploaderInterop is not null)
|
||||
await _uploaderInterop.DisposeAsync();
|
||||
|
||||
if (HttpClient is not null)
|
||||
await CastAndDispose(HttpClient);
|
||||
|
||||
return;
|
||||
|
||||
static async ValueTask CastAndDispose(IDisposable resource)
|
||||
{
|
||||
if (resource is IAsyncDisposable resourceAsyncDisposable)
|
||||
await resourceAsyncDisposable.DisposeAsync();
|
||||
else
|
||||
resource.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
148
LANCommander.UI/Components/UploadManager/UploadManager.ts
Normal file
148
LANCommander.UI/Components/UploadManager/UploadManager.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { Chunk } from '../ChunkUploader/Chunk';
|
||||
import { UploadInitRequest } from '../ChunkUploader/UploadInitRequest';
|
||||
import axios, { AxiosProgressEvent, CancelTokenSource } from 'axios';
|
||||
|
||||
interface ActiveUpload {
|
||||
uploadId: string;
|
||||
file: File;
|
||||
key: string;
|
||||
storageLocationId: string;
|
||||
totalChunks: number;
|
||||
chunks: Chunk[];
|
||||
cancelSource: CancelTokenSource;
|
||||
}
|
||||
|
||||
export class UploadManager {
|
||||
private static _instance: UploadManager;
|
||||
private _dotNetRef: any;
|
||||
private _uploads: Map<string, ActiveUpload> = new Map();
|
||||
|
||||
private readonly InitRoute: string = "/api/Upload/Init";
|
||||
private readonly ChunkRoute: string = "/api/Upload/Chunk";
|
||||
private readonly MaxChunkSize: number = 1024 * 1024 * 50;
|
||||
|
||||
public static Create(): UploadManager {
|
||||
if (!UploadManager._instance)
|
||||
UploadManager._instance = new UploadManager();
|
||||
|
||||
return UploadManager._instance;
|
||||
}
|
||||
|
||||
Initialize(dotNetRef: any) {
|
||||
this._dotNetRef = dotNetRef;
|
||||
}
|
||||
|
||||
async StartUpload(uploadId: string, fileInputId: string, storageLocationId: string, objectKey: string) {
|
||||
const fileInput = document.getElementById(fileInputId) as HTMLInputElement;
|
||||
|
||||
if (!fileInput?.files?.length)
|
||||
return;
|
||||
|
||||
const file = fileInput.files.item(0)!;
|
||||
|
||||
let key = objectKey;
|
||||
|
||||
if (!key || key === "") {
|
||||
const request = new UploadInitRequest();
|
||||
request.storageLocationId = storageLocationId;
|
||||
request.key = "";
|
||||
|
||||
const response = await axios.post<string>(this.InitRoute, request);
|
||||
key = response.data;
|
||||
}
|
||||
|
||||
const totalChunks = Math.ceil(file.size / this.MaxChunkSize);
|
||||
const chunks: Chunk[] = [];
|
||||
|
||||
for (let i = 1; i <= totalChunks; i++) {
|
||||
const start = (i - 1) * this.MaxChunkSize;
|
||||
let end = (i * this.MaxChunkSize) - 1;
|
||||
if (i === totalChunks) end = file.size;
|
||||
chunks.push(new Chunk(key, start, end, i));
|
||||
}
|
||||
|
||||
const cancelSource = axios.CancelToken.source();
|
||||
|
||||
const upload: ActiveUpload = {
|
||||
uploadId,
|
||||
file,
|
||||
key,
|
||||
storageLocationId,
|
||||
totalChunks,
|
||||
chunks,
|
||||
cancelSource,
|
||||
};
|
||||
|
||||
this._uploads.set(uploadId, upload);
|
||||
|
||||
this.processUpload(upload);
|
||||
}
|
||||
|
||||
CancelUpload(uploadId: string) {
|
||||
const upload = this._uploads.get(uploadId);
|
||||
if (upload) {
|
||||
upload.cancelSource.cancel('Upload cancelled by user');
|
||||
this._uploads.delete(uploadId);
|
||||
}
|
||||
}
|
||||
|
||||
GetActiveUploadIds(): string[] {
|
||||
return Array.from(this._uploads.keys());
|
||||
}
|
||||
|
||||
private async processUpload(upload: ActiveUpload) {
|
||||
try {
|
||||
for (const chunk of upload.chunks) {
|
||||
await this.uploadChunk(upload, chunk);
|
||||
}
|
||||
|
||||
this._uploads.delete(upload.uploadId);
|
||||
|
||||
if (this._dotNetRef) {
|
||||
this._dotNetRef.invokeMethodAsync('JSOnUploadComplete', upload.uploadId, upload.key);
|
||||
}
|
||||
} catch (ex) {
|
||||
this._uploads.delete(upload.uploadId);
|
||||
|
||||
if (axios.isCancel(ex))
|
||||
return;
|
||||
|
||||
const message = ex instanceof Error ? ex.message : String(ex);
|
||||
|
||||
if (this._dotNetRef) {
|
||||
this._dotNetRef.invokeMethodAsync('JSOnUploadError', upload.uploadId, message);
|
||||
}
|
||||
|
||||
console.error(`Upload ${upload.uploadId} failed: ${ex}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadChunk(upload: ActiveUpload, chunk: Chunk) {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', upload.file.slice(chunk.Start, chunk.End + 1));
|
||||
formData.append('start', chunk.Start.toString());
|
||||
formData.append('end', chunk.End.toString());
|
||||
formData.append('key', upload.key);
|
||||
formData.append('total', upload.file.size.toString());
|
||||
|
||||
await axios({
|
||||
method: "post",
|
||||
url: this.ChunkRoute,
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
cancelToken: upload.cancelSource.token,
|
||||
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
|
||||
const percent = ((1 / upload.totalChunks) * (progressEvent.progress ?? 0)) + ((chunk.Index - 1) / upload.totalChunks);
|
||||
const rate = progressEvent.rate ?? 0;
|
||||
|
||||
if (this._dotNetRef) {
|
||||
// Only send rate when it's a meaningful value; the C# side
|
||||
// preserves the last known speed so it doesn't flicker to 0
|
||||
// between chunks.
|
||||
this._dotNetRef.invokeMethodAsync('JSOnUploadProgress', upload.uploadId, Math.ceil(percent * 100), rate);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using LANCommander.UI.Providers;
|
||||
using LANCommander.UI.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LANCommander.UI.Extensions;
|
||||
|
|
@ -9,6 +10,7 @@ public static class IServiceCollectionExtensions
|
|||
{
|
||||
services.AddSingleton<TimeProvider, LocalTimeProvider>();
|
||||
services.AddScoped<ScriptProvider>();
|
||||
services.AddScoped<UploadTracker>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
|
|
|||
27
LANCommander.UI/Services/UploadInfo.cs
Normal file
27
LANCommander.UI/Services/UploadInfo.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
namespace LANCommander.UI.Services;
|
||||
|
||||
public enum UploadType
|
||||
{
|
||||
Archive,
|
||||
Import,
|
||||
}
|
||||
|
||||
public enum UploadStatus
|
||||
{
|
||||
Uploading,
|
||||
Complete,
|
||||
Error,
|
||||
}
|
||||
|
||||
public class BackgroundUploadInfo
|
||||
{
|
||||
public string UploadId { get; set; } = string.Empty;
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public int Percent { get; set; }
|
||||
public double Speed { get; set; }
|
||||
public UploadType Type { get; set; }
|
||||
public UploadStatus Status { get; set; } = UploadStatus.Uploading;
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? CompletedObjectKey { get; set; }
|
||||
public Func<string, Task>? OnCompleted { get; set; }
|
||||
}
|
||||
148
LANCommander.UI/Services/UploadTracker.cs
Normal file
148
LANCommander.UI/Services/UploadTracker.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
using LANCommander.UI.Providers;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace LANCommander.UI.Services;
|
||||
|
||||
public class UploadTracker : IAsyncDisposable
|
||||
{
|
||||
private readonly ScriptProvider _scriptProvider;
|
||||
private IJSObjectReference? _managerInterop;
|
||||
private DotNetObjectReference<UploadTracker>? _dotNetRef;
|
||||
private bool _initialized;
|
||||
|
||||
public Dictionary<string, BackgroundUploadInfo> ActiveUploads { get; } = new();
|
||||
|
||||
public event Action? OnStateChanged;
|
||||
public event Func<BackgroundUploadInfo, Task>? OnUploadCompleted;
|
||||
|
||||
public UploadTracker(ScriptProvider scriptProvider)
|
||||
{
|
||||
_scriptProvider = scriptProvider;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
if (_initialized)
|
||||
return;
|
||||
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_managerInterop = await _scriptProvider.ImportModuleAsync("UploadManager");
|
||||
await _managerInterop.InvokeVoidAsync("Initialize", _dotNetRef);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
public async Task<string> StartUploadAsync(
|
||||
string fileInputId,
|
||||
Guid storageLocationId,
|
||||
string fileName,
|
||||
UploadType type,
|
||||
string? objectKey = null,
|
||||
Func<string, Task>? onCompleted = null)
|
||||
{
|
||||
await InitializeAsync();
|
||||
|
||||
var uploadId = Guid.NewGuid().ToString();
|
||||
|
||||
var info = new BackgroundUploadInfo
|
||||
{
|
||||
UploadId = uploadId,
|
||||
FileName = fileName,
|
||||
Type = type,
|
||||
Status = UploadStatus.Uploading,
|
||||
OnCompleted = onCompleted,
|
||||
};
|
||||
|
||||
ActiveUploads[uploadId] = info;
|
||||
OnStateChanged?.Invoke();
|
||||
|
||||
await _managerInterop!.InvokeVoidAsync("StartUpload", uploadId, fileInputId, storageLocationId.ToString(), objectKey ?? "");
|
||||
|
||||
return uploadId;
|
||||
}
|
||||
|
||||
public async Task CancelUploadAsync(string uploadId)
|
||||
{
|
||||
if (_managerInterop != null)
|
||||
await _managerInterop.InvokeVoidAsync("CancelUpload", uploadId);
|
||||
|
||||
ActiveUploads.Remove(uploadId);
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public bool HasActiveUploads => ActiveUploads.Values.Any(u => u.Status == UploadStatus.Uploading);
|
||||
|
||||
[JSInvokable]
|
||||
public async Task JSOnUploadProgress(string uploadId, int percent, double rate)
|
||||
{
|
||||
if (ActiveUploads.TryGetValue(uploadId, out var info))
|
||||
{
|
||||
info.Percent = percent;
|
||||
|
||||
// Preserve the last known speed when rate is 0 (between chunks)
|
||||
if (rate > 0)
|
||||
info.Speed = rate;
|
||||
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task JSOnUploadComplete(string uploadId, string objectKey)
|
||||
{
|
||||
if (ActiveUploads.TryGetValue(uploadId, out var info))
|
||||
{
|
||||
info.Status = UploadStatus.Complete;
|
||||
info.Percent = 100;
|
||||
info.CompletedObjectKey = objectKey;
|
||||
OnStateChanged?.Invoke();
|
||||
|
||||
if (info.OnCompleted != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await info.OnCompleted(objectKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Upload completion handler failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (OnUploadCompleted != null)
|
||||
await OnUploadCompleted.Invoke(info);
|
||||
|
||||
// Keep completed uploads in the list so the UI can show them.
|
||||
// The UploadIndicator handles dismissal.
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task JSOnUploadError(string uploadId, string message)
|
||||
{
|
||||
if (ActiveUploads.TryGetValue(uploadId, out var info))
|
||||
{
|
||||
info.Status = UploadStatus.Error;
|
||||
info.ErrorMessage = message;
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void RemoveUpload(string uploadId)
|
||||
{
|
||||
ActiveUploads.Remove(uploadId);
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_managerInterop != null)
|
||||
await _managerInterop.DisposeAsync();
|
||||
|
||||
_dotNetRef?.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -7,4 +7,5 @@ export { TimeProvider } from "./Components/LocalTime/TimeProvider";
|
|||
export { Terminal } from "./Components/Terminal/Terminal";
|
||||
export { DomHelper } from "./Components/DomHelper/DomHelper";
|
||||
export { registerPowerShellCompletions, setScriptType, validateScript, insertSnippet, getScriptTemplate } from "./Components/MonacoCodeEditor/PowerShellCompletionProvider";
|
||||
export { registerYamlCompletions } from "./Components/MonacoCodeEditor/YamlCompletionProvider";
|
||||
export { registerYamlCompletions } from "./Components/MonacoCodeEditor/YamlCompletionProvider";
|
||||
export { UploadManager } from "./Components/UploadManager/UploadManager";
|
||||
Loading…
Add table
Add a link
Reference in a new issue