@@ -68,18 +65,21 @@
[Parameter] public Guid StorageLocationId { get; set; }
[Parameter] public EventCallback StorageLocationIdChanged { get; set; }
+ ///
+ /// The type of upload for tracking purposes.
+ ///
+ [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)
+ ///
+ /// Starts the upload. The onCompleted callback is invoked when the upload finishes
+ /// and survives component disposal.
+ ///
+ public async Task Start(Func? onCompleted = null)
{
- if (firstRender)
- _uploaderInterop ??= await ScriptProvider.ImportModuleAsync();
- }
-
- 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();
- }
- }
-
}
diff --git a/LANCommander.UI/Components/UploadManager/UploadManager.ts b/LANCommander.UI/Components/UploadManager/UploadManager.ts
new file mode 100644
index 00000000..4e68ddfe
--- /dev/null
+++ b/LANCommander.UI/Components/UploadManager/UploadManager.ts
@@ -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 = 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(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);
+ }
+ }
+ });
+ }
+}
diff --git a/LANCommander.UI/Extensions/IServiceCollectionExtensions.cs b/LANCommander.UI/Extensions/IServiceCollectionExtensions.cs
index 362059bf..aaa0bca5 100644
--- a/LANCommander.UI/Extensions/IServiceCollectionExtensions.cs
+++ b/LANCommander.UI/Extensions/IServiceCollectionExtensions.cs
@@ -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();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/LANCommander.UI/Services/UploadInfo.cs b/LANCommander.UI/Services/UploadInfo.cs
new file mode 100644
index 00000000..ff2f3519
--- /dev/null
+++ b/LANCommander.UI/Services/UploadInfo.cs
@@ -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? OnCompleted { get; set; }
+}
diff --git a/LANCommander.UI/Services/UploadTracker.cs b/LANCommander.UI/Services/UploadTracker.cs
new file mode 100644
index 00000000..b7815da3
--- /dev/null
+++ b/LANCommander.UI/Services/UploadTracker.cs
@@ -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? _dotNetRef;
+ private bool _initialized;
+
+ public Dictionary ActiveUploads { get; } = new();
+
+ public event Action? OnStateChanged;
+ public event Func? 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 StartUploadAsync(
+ string fileInputId,
+ Guid storageLocationId,
+ string fileName,
+ UploadType type,
+ string? objectKey = null,
+ Func? 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();
+ }
+}
diff --git a/LANCommander.UI/_Imports.razor.ts b/LANCommander.UI/_Imports.razor.ts
index da08e38e..9c62699a 100644
--- a/LANCommander.UI/_Imports.razor.ts
+++ b/LANCommander.UI/_Imports.razor.ts
@@ -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";
\ No newline at end of file
+export { registerYamlCompletions } from "./Components/MonacoCodeEditor/YamlCompletionProvider";
+export { UploadManager } from "./Components/UploadManager/UploadManager";
\ No newline at end of file