LANCommander/LANCommander.Server/UI/Components/ImportUploadDialog.razor
2026-05-28 22:26:24 -05:00

432 lines
13 KiB
Text

@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
@if (_stage == ImportStage.Upload)
{
<Flex Vertical Gap="FlexGap.Small">
<ChunkUploader
@ref="ChunkUploader"
Accept=".lcx"
@bind-File="File"
@bind-Status="Status"
StorageLocationId="StorageLocation.Id"
UploadType="UploadType.Import"
OnUploadError="OnUploadError">
<Text>
<p>Drag and Drop</p>
<p>or</p>
<p>
<Button Type="@ButtonType.Primary" Style="margin-top: 8px;">Browse</Button>
</p>
</Text>
<Hint>@Options.Hint</Hint>
</ChunkUploader>
<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"))"
OnSelected="OnLocalFileSelected"
Root="@RootPath"
Disabled="@(Status != "")">
Use Local File
</FilePickerButton>
<Button OnClick="Upload" Disabled="@(File == null || Status != "")" Type="@ButtonType.Primary">Upload</Button>
<Button OnClick="Cancel">Cancel</Button>
</Flex>
}
else if (_stage == ImportStage.RecordSelection)
{
<Tree TItem="string"
Checkable
Multiple
CheckOnClickNode="false"
@bind-CheckedKeys="_selectedKeys">
@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")>
<ByteSize Value="@size" />
</GridCol>
}
</GridRow>
</TitleTemplate>
<ChildContent>
@foreach (var item in group)
{
<TreeNode Key="@item.Key">
<TitleTemplate>
<GridRow>
<GridCol Flex=@("auto")>
@item.Name
</GridCol>
@if (item.Size > 0)
{
<GridCol Flex=@("none")>
<ByteSize Value="@item.Size"/>
</GridCol>
}
</GridRow>
</TitleTemplate>
</TreeNode>
}
</ChildContent>
</TreeNode>
}
</Tree>
<Flex Justify="FlexJustify.End" Gap="FlexGap.Small" Style="margin-top: 16px;">
<Button OnClick="Import" Type="@ButtonType.Primary">Import</Button>
<Button OnClick="Cancel">Cancel</Button>
</Flex>
}
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)
{
<Alert Type="AlertType.Error" Message="@error" />
}
</Flex>
</Flex>
}
else if (_stage == ImportStage.Complete)
{
<Result Status="ResultStatus.Success" Title="Import Complete!">
<Extra>
<Button Type="ButtonType.Primary" OnClick="CloseFeedbackAsync">Close</Button>
</Extra>
</Result>
}
else if (_stage == ImportStage.Failed)
{
<Result Status="ResultStatus.Error" Title="Import Failed!">
<Extra>
<Flex Gap="FlexGap.Small" Direction="FlexDirection.Vertical">
@foreach (var error in _errors)
{
<Alert Type="AlertType.Error" Message="@error" />
}
<Button Type="ButtonType.Primary" OnClick="CloseFeedbackAsync">Close</Button>
</Flex>
</Extra>
</Result>
}
@code {
enum ImportStage
{
Upload,
RecordSelection,
Importing,
Complete,
Failed,
};
IBrowserFile File;
ChunkUploader ChunkUploader;
StorageLocation StorageLocation = new();
ImportContext ImportContext;
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;
List<string> _errors = new();
int _importProgress = -1;
int _importTotal = 0;
string _currentImportItemName = String.Empty;
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;
_importProgress = 100;
await Clear();
ImportContext.Dispose();
await CleanupUploadArchiveAsync();
await Task.Yield();
await InvokeStateHasChangedAsync();
}
private async Task ImportStatusUpdate(ImportStatusUpdate status) => await UpdateProgress(status);
private async Task ImportError(ImportStatusUpdate status)
{
if (status.Error != null)
_errors.Add(status.Error);
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();
}
}
async Task Upload()
{
_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()
{
_stage = ImportStage.Importing;
var selectedGuids = _selectedKeys
.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);
await ImportContext.ImportQueueAsync();
}
catch (Exception ex)
{
_stage = ImportStage.Failed;
Logger?.LogError(ex, "An unknown error occurred while trying to import");
ImportContext.Dispose();
await CleanupUploadArchiveAsync();
}
}
async Task Clear()
{
if (ChunkUploader != null)
await ChunkUploader.Clear();
}
async Task Cancel()
{
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();
await CleanupUploadArchiveAsync();
await base.OnFeedbackCancelAsync(args);
}
public override async Task OnFeedbackOkAsync(ModalClosingEventArgs args)
{
if (ChunkUploader != null)
await ChunkUploader.Clear();
ImportContext.Dispose();
await CleanupUploadArchiveAsync();
await base.OnFeedbackOkAsync(args);
}
public async Task OnUploadCompleted(string data)
{
if (Guid.TryParse(data, out var objectKey))
{
try
{
_uploadObjectKey = objectKey.ToString();
var archivePath = await ArchiveService.GetArchiveFileLocationAsync(_uploadObjectKey);
ImportItems = await ImportContext.InitializeImportAsync(archivePath, Options.ManifestType);
_selectedKeys = ImportItems.Select(i => i.Key).ToArray();
_stage = ImportStage.RecordSelection;
_uploading = false;
await InvokeAsync(StateHasChanged);
}
catch (Exception ex)
{
Logger?.LogError(ex, "An unknown error occurred while trying to import");
MessageService.Error("An unknown error occurred while trying to import");
}
}
else
{
await InvokeAsync(StateHasChanged);
MessageService.Error("Import file failed to upload!");
Logger.LogError("Import file failed to upload!");
await (base.OkCancelRef?.OnCancel?.Invoke() ?? Task.CompletedTask);
await CloseFeedbackAsync();
}
}
public async Task OnUploadError(string message)
{
_uploading = false;
await InvokeAsync(StateHasChanged);
MessageService.Error("An error occurred while trying to import");
Logger?.LogError($"An error occurred while trying to import: {message}");
await CloseFeedbackAsync();
}
async Task CleanupUploadArchiveAsync()
{
if (!string.IsNullOrEmpty(_uploadObjectKey))
{
try
{
var archive = await ArchiveService.FirstOrDefaultAsync(a => a.ObjectKey == _uploadObjectKey);
if (archive != null)
await ArchiveService.DeleteAsync(archive);
}
catch (Exception ex)
{
Logger?.LogWarning(ex, "Could not clean up upload archive record for {ObjectKey}", _uploadObjectKey);
}
_uploadObjectKey = null;
}
}
public async Task OnLocalFileSelected(string path)
{
try
{
ImportItems = await ImportContext.InitializeImportAsync(path, Options.ManifestType);
_selectedKeys = ImportItems.Select(i => i.Key).ToArray();
_stage = ImportStage.RecordSelection;
await InvokeAsync(StateHasChanged);
}
catch (Exception ex)
{
Logger?.LogError(ex, "An unknown error occurred while trying to import");
MessageService.Error("An unknown error occurred while trying to import");
}
}
}