From 4948dbc0d5d5b425c7d94352ab811767f552946a Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Thu, 28 May 2026 22:24:46 -0500 Subject: [PATCH] Add background uploading Fixes #298 --- .../Models/ImportDialogOptions.cs | 6 + .../UI/Components/ArchiveUploader.razor | 196 ++++++++++------- .../UI/Components/ImportUploadDialog.razor | 105 ++++++--- .../UI/Components/Sidebar.razor | 7 +- .../UI/Components/UploadIndicator.razor | 203 ++++++++++++++++++ .../UI/Shared/MainLayout.razor | 16 +- .../ChunkUploader/ChunkUploader.razor | 158 +++++++------- .../Components/UploadManager/UploadManager.ts | 148 +++++++++++++ .../IServiceCollectionExtensions.cs | 2 + LANCommander.UI/Services/UploadInfo.cs | 27 +++ LANCommander.UI/Services/UploadTracker.cs | 148 +++++++++++++ LANCommander.UI/_Imports.razor.ts | 3 +- 12 files changed, 831 insertions(+), 188 deletions(-) create mode 100644 LANCommander.Server/UI/Components/UploadIndicator.razor create mode 100644 LANCommander.UI/Components/UploadManager/UploadManager.ts create mode 100644 LANCommander.UI/Services/UploadInfo.cs create mode 100644 LANCommander.UI/Services/UploadTracker.cs diff --git a/LANCommander.Server/Models/ImportDialogOptions.cs b/LANCommander.Server/Models/ImportDialogOptions.cs index 35d1d5a7..924f5255 100644 --- a/LANCommander.Server/Models/ImportDialogOptions.cs +++ b/LANCommander.Server/Models/ImportDialogOptions.cs @@ -6,4 +6,10 @@ public class ImportDialogOptions { public string Hint { get; set; } public ManifestType? ManifestType { get; set; } + + /// + /// When set, the import dialog skips the upload stage and goes directly to record selection + /// using this pre-uploaded archive's object key. + /// + public string? PreUploadedObjectKey { get; set; } } \ No newline at end of file diff --git a/LANCommander.Server/UI/Components/ArchiveUploader.razor b/LANCommander.Server/UI/Components/ArchiveUploader.razor index 8edc047d..3d4eec26 100644 --- a/LANCommander.Server/UI/Components/ArchiveUploader.razor +++ b/LANCommander.Server/UI/Components/ArchiveUploader.razor @@ -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 @inject IMessageService MessageService +@inject ConfirmService ConfirmService @inject IJSRuntime JS @inject ILogger Logger @@ -22,10 +24,10 @@ ; } - +
- + @@ -37,7 +39,7 @@ - +

Drag and Drop

or

@@ -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(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(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) { diff --git a/LANCommander.Server/UI/Components/ImportUploadDialog.razor b/LANCommander.Server/UI/Components/ImportUploadDialog.razor index de3f2ca9..297d7e52 100644 --- a/LANCommander.Server/UI/Components/ImportUploadDialog.razor +++ b/LANCommander.Server/UI/Components/ImportUploadDialog.razor @@ -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 @inject ArchiveService ArchiveService @inject StorageLocationService StorageLocationService @inject ImportContextFactory ImportContextFactory +@inject UploadTracker UploadTracker +@inject ConfirmService ConfirmService @inject IMessageService MessageService @inject ILogger Logger @@ -18,7 +21,7 @@ @bind-File="File" @bind-Status="Status" StorageLocationId="StorageLocation.Id" - OnUploadCompleted="OnUploadCompleted" + UploadType="UploadType.Import" OnUploadError="OnUploadError">

Drag and Drop

@@ -32,7 +35,7 @@ - + i.Type)) { var size = group.Sum(i => i.Size); - + @group.Key.GetDisplayName() (@group.Count()) - + @if (size > 0) { @@ -105,12 +108,12 @@ else if (_stage == ImportStage.Importing) { - + @if (!String.IsNullOrWhiteSpace(_currentImportItemName)) { Importing @_currentImportItemName } - + @foreach (var error in _errors) { @@ -136,7 +139,7 @@ else if (_stage == ImportStage.Failed) { } - + @@ -160,12 +163,13 @@ else if (_stage == ImportStage.Failed) string _uploadObjectKey; IEnumerable ImportItems = new List(); - + 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"); } } -} \ No newline at end of file +} diff --git a/LANCommander.Server/UI/Components/Sidebar.razor b/LANCommander.Server/UI/Components/Sidebar.razor index 7ff41003..cf6fb6a3 100644 --- a/LANCommander.Server/UI/Components/Sidebar.razor +++ b/LANCommander.Server/UI/Components/Sidebar.razor @@ -2,10 +2,13 @@ - + - + + + + \ No newline at end of file diff --git a/LANCommander.Server/UI/Components/UploadIndicator.razor b/LANCommander.Server/UI/Components/UploadIndicator.razor new file mode 100644 index 00000000..3dc0b2ca --- /dev/null +++ b/LANCommander.Server/UI/Components/UploadIndicator.razor @@ -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()) +{ + + +
+ @foreach (var upload in UploadTracker.ActiveUploads.Values.ToList()) + { +
+ + + @upload.FileName + + + @if (upload.Status == UploadStatus.Complete && upload.Type == UploadType.Import && !string.IsNullOrEmpty(upload.CompletedObjectKey)) + { +
+ } +
+
+ + + + + + Uploads + + + + +
+} + +@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