Add background uploading

Fixes #298
This commit is contained in:
Pat Hartl 2026-05-28 22:24:46 -05:00
parent 19a63491fc
commit 4948dbc0d5
12 changed files with 831 additions and 188 deletions

View file

@ -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; }
}

View file

@ -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)
{

View file

@ -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");
}
}
}
}

View file

@ -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>

View 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;
}
}

View file

@ -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();
}
}
}

View file

@ -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();
}
}
}

View 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);
}
}
});
}
}

View file

@ -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;
}

View 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; }
}

View 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();
}
}

View file

@ -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";