feat(centralui): TransportImport wizard under Design nav group

This commit is contained in:
Joseph Doherty
2026-05-24 05:38:09 -04:00
parent 0dbc0c02f9
commit acadb83712
3 changed files with 1269 additions and 0 deletions

View File

@@ -0,0 +1,429 @@
@page "/design/transport/import"
@using ScadaLink.Security
@using ScadaLink.Commons.Types.Transport
@using ScadaLink.Commons.Interfaces.Transport
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Options
@using ScadaLink.Transport
@using ScadaLink.Transport.Import
@using System.Security.Cryptography
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@*
TransportImport wizard (Component #24, Task T22).
A 5-step linear wizard:
Step 1 — Upload : InputFile + manifest summary; LoadAsync without passphrase first.
Step 2 — Passphrase : only shown for encrypted bundles; 3-strike lockout.
Step 3 — Diff : conflict resolution (Add/Overwrite/Skip/Rename) per ImportPreviewItem.
Step 4 — Confirm : type-the-environment-name guard.
Step 5 — Result : ApplyAsync result + audit drilldown link.
The page is Admin-only — Import touches central configuration globally.
*@
<div class="container-fluid mt-3">
<h4 class="mb-3">Import Bundle</h4>
@* Step indicator — five numbered pills, mirrors TransportExport. *@
<nav aria-label="Import wizard steps" class="mb-4">
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
<li class="@StepClass(ImportWizardStep.Upload)">
<span class="badge rounded-pill me-1">1</span> Upload
</li>
<li class="@StepClass(ImportWizardStep.Passphrase)">
<span class="badge rounded-pill me-1">2</span> Passphrase
</li>
<li class="@StepClass(ImportWizardStep.Diff)">
<span class="badge rounded-pill me-1">3</span> Diff
</li>
<li class="@StepClass(ImportWizardStep.Confirm)">
<span class="badge rounded-pill me-1">4</span> Confirm
</li>
<li class="@StepClass(ImportWizardStep.Result)">
<span class="badge rounded-pill me-1">5</span> Result
</li>
</ol>
</nav>
@if (_errorMessage != null)
{
<div class="alert alert-danger" data-testid="error-message">@_errorMessage</div>
}
@switch (_step)
{
case ImportWizardStep.Upload:
@RenderStepUpload();
break;
case ImportWizardStep.Passphrase:
@RenderStepPassphrase();
break;
case ImportWizardStep.Diff:
@RenderStepDiff();
break;
case ImportWizardStep.Confirm:
@RenderStepConfirm();
break;
case ImportWizardStep.Result:
@RenderStepResult();
break;
}
</div>
@code {
private string StepClass(ImportWizardStep s) =>
s == _step ? "fw-semibold text-primary"
: (int)s < (int)_step ? "text-success"
: "text-muted";
// ============================================================
// Step 1 — Upload
// ============================================================
private RenderFragment RenderStepUpload() => __builder =>
{
<div>
<p class="text-body-secondary">
Select a <code>.scadabundle</code> file produced by an exporter on this
or another cluster. The bundle's manifest will be validated immediately;
encrypted bundles will prompt for a passphrase on the next step.
</p>
<div class="mb-3">
<label for="bundle-input" class="form-label">Bundle file</label>
<InputFile id="bundle-input" OnChange="OnFileSelectedAsync"
class="form-control" accept=".scadabundle,application/zip" />
<div class="form-text">
Maximum bundle size: @Options.Value.MaxBundleSizeMb MB.
</div>
</div>
@if (_uploadInProgress)
{
<div class="text-muted small fst-italic">Reading bundle…</div>
}
@if (_session is not null)
{
<dl class="row small mt-3" data-testid="manifest-summary">
<dt class="col-sm-3">Source environment</dt>
<dd class="col-sm-9"><code>@_session.Manifest.SourceEnvironment</code></dd>
<dt class="col-sm-3">Exported by</dt>
<dd class="col-sm-9">@_session.Manifest.ExportedBy</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">@_session.Manifest.CreatedAtUtc.ToString("u")</dd>
<dt class="col-sm-3">Content count</dt>
<dd class="col-sm-9">@_session.Manifest.Contents.Count items</dd>
<dt class="col-sm-3">SHA-256</dt>
<dd class="col-sm-9"><code class="small">@_session.Manifest.ContentHash</code></dd>
<dt class="col-sm-3">Encryption</dt>
<dd class="col-sm-9">
@if (_session.Manifest.Encryption is null)
{
<span class="text-warning">Unencrypted</span>
}
else
{
<span class="text-success">@_session.Manifest.Encryption.Algorithm</span>
}
</dd>
</dl>
<div class="d-flex justify-content-end mt-3">
<button class="btn btn-primary" @onclick="GoFromUploadAsync">Next</button>
</div>
}
</div>
};
// ============================================================
// Step 2 — Passphrase
// ============================================================
private RenderFragment RenderStepPassphrase() => __builder =>
{
var maxAttempts = Options.Value.MaxUnlockAttemptsPerSession;
var attemptsLeft = Math.Max(0, maxAttempts - _failedUnlockAttempts);
<div>
<p class="text-body-secondary">
This bundle is encrypted. Enter the passphrase that was used to
produce it. You have @attemptsLeft of @maxAttempts attempts before
the upload must be restarted.
</p>
<div class="mb-3">
<label for="import-passphrase" class="form-label">Passphrase</label>
<input id="import-passphrase" type="password" class="form-control"
autocomplete="current-password"
@bind="_passphrase" @bind:event="oninput" />
</div>
@if (_failedUnlockAttempts > 0)
{
<div class="alert alert-warning small" data-testid="unlock-attempts">
Failed unlock attempts: @_failedUnlockAttempts of @maxAttempts.
</div>
}
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
<button class="btn btn-primary"
disabled="@(string.IsNullOrEmpty(_passphrase) || _uploadInProgress)"
@onclick="SubmitPassphraseAsync">
@(_uploadInProgress ? "Unlocking…" : "Unlock")
</button>
</div>
</div>
};
// ============================================================
// Step 3 — Diff & resolve conflicts
// ============================================================
private RenderFragment RenderStepDiff() => __builder =>
{
if (_preview is null || _resolutions is null)
{
<div class="alert alert-warning">No preview available — please go back and re-upload.</div>
return;
}
var (adds, overs, skips, renames, blockers) = CountResolutions();
var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
<div>
<p class="text-body-secondary">
Review each artifact in the bundle and choose how it should be applied
to this environment. Identical items are skipped automatically; new
items default to Add; modified items require an explicit choice.
</p>
<div class="mb-3 d-flex flex-wrap gap-2 align-items-center" data-testid="bulk-actions">
<span class="small text-body-secondary">Apply to all modified:</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Skip)">Skip</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Overwrite)">Overwrite</button>
</div>
<div class="table-responsive" style="max-height: 480px; overflow-y: auto;">
<table class="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Status</th>
<th>Existing</th>
<th>Incoming</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@foreach (var item in _preview.Items)
{
var key = (item.EntityType, item.Name);
var current = _resolutions[key];
<tr data-testid="diff-row">
<td><span class="badge bg-secondary">@item.EntityType</span></td>
<td>@item.Name</td>
<td>@RenderKindBadge(item)</td>
<td>@(item.ExistingVersion?.ToString() ?? "—")</td>
<td>@(item.IncomingVersion?.ToString() ?? "—")</td>
<td>@RenderResolutionControls(item, current)</td>
</tr>
@if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson))
{
<tr>
<td colspan="6">
<details>
<summary class="small">Field diff</summary>
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
</details>
</td>
</tr>
}
@if (item.Kind == ConflictKind.Blocker && !string.IsNullOrEmpty(item.BlockerReason))
{
<tr>
<td colspan="6">
<div class="alert alert-danger small mb-0">@item.BlockerReason</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
<div>
<span class="me-3 small text-body-secondary" data-testid="diff-summary">
@adds add · @overs overwrite · @skips skip · @renames rename · @blockers blocker
</span>
<button class="btn btn-primary"
disabled="@hasBlockers"
@onclick="GoToConfirm">
Next
</button>
</div>
</div>
</div>
};
private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder =>
{
var (cls, label) = item.Kind switch
{
ConflictKind.Identical => ("bg-secondary", "Identical"),
ConflictKind.Modified => ("bg-warning text-dark", "Modified"),
ConflictKind.New => ("bg-success", "New"),
ConflictKind.Blocker => ("bg-danger", "Blocker"),
_ => ("bg-light text-dark", item.Kind.ToString()),
};
<span class="badge @cls">@label</span>
};
private RenderFragment RenderResolutionControls(ImportPreviewItem item, ImportResolution current) => __builder =>
{
// Identical → forced Skip; New → forced Add; Blocker → no actions.
if (item.Kind == ConflictKind.Identical)
{
<span class="text-muted small">Skip</span>
return;
}
if (item.Kind == ConflictKind.New)
{
<span class="text-muted small">Add</span>
return;
}
if (item.Kind == ConflictKind.Blocker)
{
<span class="text-muted small">—</span>
return;
}
var key = (item.EntityType, item.Name);
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var action in new[] { ResolutionAction.Overwrite, ResolutionAction.Skip, ResolutionAction.Rename })
{
var inputId = $"res-{item.EntityType}-{item.Name}-{action}";
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="radio"
id="@inputId"
name="@($"res-{item.EntityType}-{item.Name}")"
checked="@(current.Action == action)"
@onchange="() => SetResolution(key, action)" />
<label class="form-check-label small" for="@inputId">@action</label>
</div>
}
@if (current.Action == ResolutionAction.Rename)
{
<input type="text" class="form-control form-control-sm"
style="max-width: 14rem;"
placeholder="New name"
value="@(current.RenameTo ?? string.Empty)"
@onchange="e => SetRenameTo(key, e.Value?.ToString())" />
}
</div>
};
// ============================================================
// Step 4 — Confirm
// ============================================================
private RenderFragment RenderStepConfirm() => __builder =>
{
if (_session is null)
{
<div class="alert alert-warning">No bundle session — please re-upload.</div>
return;
}
var (adds, overs, skips, renames, _) = CountResolutions();
var changeCount = adds + overs + renames;
<div>
<p class="text-body-secondary">
You are about to apply <strong>@changeCount</strong> change(s)
to this environment (@adds add · @overs overwrite · @skips skip · @renames rename).
</p>
<div class="alert alert-info small">
Affected instances will become stale and require redeployment via the
<a href="/deployment/deployments">Deployments</a> page.
</div>
<div class="mb-3">
<label for="confirm-env" class="form-label">
Type the source environment name <code>@_session.Manifest.SourceEnvironment</code> to confirm:
</label>
<input id="confirm-env" type="text" class="form-control"
@bind="_confirmEnvironmentText" @bind:event="oninput" />
</div>
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
<button class="btn btn-danger"
disabled="@(_confirmEnvironmentText != _session.Manifest.SourceEnvironment || _applyInProgress)"
@onclick="ApplyAsync">
@(_applyInProgress ? "Applying…" : "Apply Import")
</button>
</div>
</div>
};
// ============================================================
// Step 5 — Result
// ============================================================
private RenderFragment RenderStepResult() => __builder =>
{
<div>
@if (_validationErrors is not null && _validationErrors.Count > 0)
{
<div class="alert alert-danger" data-testid="validation-errors">
<strong>Bundle semantic validation failed.</strong>
<ul class="mb-0">
@foreach (var err in _validationErrors)
{
<li>@err</li>
}
</ul>
</div>
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
}
else if (_result is not null)
{
<div class="alert alert-success" data-testid="result-summary">
<strong>Import complete.</strong>
@_result.Added added · @_result.Overwritten overwritten ·
@_result.Skipped skipped · @_result.Renamed renamed.
</div>
<dl class="row small">
<dt class="col-sm-3">Bundle Import Id</dt>
<dd class="col-sm-9"><code>@_result.BundleImportId</code></dd>
</dl>
<div class="d-flex gap-3">
<a class="btn btn-outline-primary" href="/deployment/deployments">
View on Deployments →
</a>
<a class="btn btn-outline-secondary"
href="@($"/audit/configuration?bundleImportId={_result.BundleImportId}")">
Audit trail →
</a>
</div>
}
else
{
<div class="alert alert-danger">
Import failed. Please re-upload the bundle.
</div>
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
}
</div>
};
}

View File

@@ -0,0 +1,473 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Options;
using ScadaLink.CentralUI.Auth;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Transport;
using ScadaLink.Transport.Import;
namespace ScadaLink.CentralUI.Components.Pages.Design;
/// <summary>
/// Code-behind for the TransportImport wizard (Transport feature, Task T22).
///
/// Five-step state machine:
/// <list type="number">
/// <item><see cref="ImportWizardStep.Upload"/> — read bundle bytes, attempt
/// a passphrase-less <see cref="IBundleImporter.LoadAsync"/>; if the
/// bundle is encrypted, advance to Step 2 without yet opening a session.</item>
/// <item><see cref="ImportWizardStep.Passphrase"/> — collect the passphrase
/// and retry LoadAsync; 3-strike lockout per the configured
/// <see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>.</item>
/// <item><see cref="ImportWizardStep.Diff"/> — render <see cref="ImportPreview"/>
/// items, collect <see cref="ImportResolution"/> per Modified item; Apply
/// is blocked while any <see cref="ConflictKind.Blocker"/> remains.</item>
/// <item><see cref="ImportWizardStep.Confirm"/> — type-the-environment-name
/// guard prevents accidental cross-cluster overwrites.</item>
/// <item><see cref="ImportWizardStep.Result"/> — render Apply result + audit
/// drill-in link; on <see cref="SemanticValidationException"/>, surface
/// the error list and allow returning to Step 3.</item>
/// </list>
///
/// The page is gated on <c>RequireAdmin</c> — Import touches central configuration
/// globally and must not be available to Design-only or Deployment-only users.
///
/// Cached bundle bytes: because <see cref="IBundleImporter.LoadAsync"/> currently
/// peeks the manifest by attempting decryption, encrypted bundles require two
/// LoadAsync invocations. We cache the raw bytes in <c>_bundleBytes</c> after the
/// first read so the user does not need to re-select the file before entering the
/// passphrase. The bytes are cleared on Done / Back-to-Upload.
/// </summary>
public partial class TransportImport : ComponentBase
{
public enum ImportWizardStep
{
Upload = 1,
Passphrase = 2,
Diff = 3,
Confirm = 4,
Result = 5,
}
// ---- Injected services ----
[Inject] private IBundleImporter BundleImporter { get; set; } = default!;
[Inject] private NavigationManager Nav { get; set; } = default!;
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
[Inject] private IOptions<TransportOptions> Options { get; set; } = default!;
// ---- Wizard state ----
private ImportWizardStep _step = ImportWizardStep.Upload;
private string? _errorMessage;
// ---- Session + cached bytes ----
// Bundle bytes are cached so the same file can be re-attempted with a
// passphrase without forcing the user to re-pick it. Cleared in ResetAll.
private byte[]? _bundleBytes;
private BundleSession? _session;
private bool _uploadInProgress;
// ---- Step 2: passphrase ----
private string _passphrase = string.Empty;
private int _failedUnlockAttempts;
// ---- Step 3: preview + resolutions ----
private ImportPreview? _preview;
// Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup.
private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions;
// ---- Step 4: confirm ----
private string _confirmEnvironmentText = string.Empty;
// ---- Step 5: apply result ----
private bool _applyInProgress;
private ImportResult? _result;
private IReadOnlyList<string>? _validationErrors;
// ============================================================
// Step 1 — Upload
// ============================================================
/// <summary>
/// Buffers the selected file, enforces the configured size cap, then calls
/// <see cref="IBundleImporter.LoadAsync"/> with no passphrase to peek the
/// manifest. Encrypted bundles surface as <see cref="ArgumentException"/>,
/// which we catch and use to advance to Step 2 — the session is opened on
/// the second LoadAsync call once the passphrase is provided.
/// </summary>
private async Task OnFileSelectedAsync(InputFileChangeEventArgs e)
{
_errorMessage = null;
_uploadInProgress = true;
_session = null;
_bundleBytes = null;
try
{
var maxBytes = Options.Value.MaxBundleSizeMb * 1024L * 1024L;
if (e.File.Size > maxBytes)
{
_errorMessage = $"Bundle exceeds the maximum allowed size of {Options.Value.MaxBundleSizeMb} MB.";
return;
}
// OpenReadStream's MaxAllowedSize defaults to 500_000 bytes — bump
// it to the configured cap so the read doesn't throw before we get
// to the importer's own length check.
using var fileStream = e.File.OpenReadStream(maxBytes);
using var ms = new MemoryStream();
await fileStream.CopyToAsync(ms);
_bundleBytes = ms.ToArray();
await TryLoadAsync(passphrase: null);
}
catch (Exception ex)
{
_errorMessage = $"Failed to read bundle: {ex.Message}";
}
finally
{
_uploadInProgress = false;
}
}
/// <summary>
/// Attempts to open a <see cref="BundleSession"/> from the cached bytes with
/// the given passphrase. On <see cref="ArgumentException"/> (encrypted bundle
/// with no passphrase) leaves the wizard's step caller to advance to the
/// passphrase step. Wrong-passphrase failures surface as
/// <see cref="CryptographicException"/> and are counted by the caller.
/// </summary>
private async Task TryLoadAsync(string? passphrase)
{
if (_bundleBytes is null)
{
_errorMessage = "No bundle bytes cached — please re-select the file.";
return;
}
try
{
using var stream = new MemoryStream(_bundleBytes);
_session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
_errorMessage = null;
}
catch (ArgumentException)
{
// Encrypted bundle, no passphrase supplied — caller advances to Step 2.
// We deliberately do NOT set _errorMessage here; the page surfaces
// an empty Step-2 prompt instead.
_session = null;
throw;
}
catch (CryptographicException)
{
// Wrong passphrase — bubble so the caller can increment the counter.
_session = null;
throw;
}
catch (InvalidDataException ex)
{
_session = null;
_errorMessage = $"Bundle is invalid: {ex.Message}";
}
catch (NotSupportedException ex)
{
_session = null;
_errorMessage = $"Bundle format unsupported: {ex.Message}";
}
catch (InvalidOperationException ex)
{
_session = null;
_errorMessage = ex.Message;
}
}
/// <summary>
/// Advances from Step 1 to either the passphrase step (encrypted bundle) or
/// straight to the diff step (unencrypted bundle). For encrypted bundles
/// LoadAsync was already attempted with <c>null</c> and threw
/// <see cref="ArgumentException"/>, so <c>_session</c> is null and we move
/// to Step 2. For unencrypted bundles <c>_session</c> is already populated;
/// jump directly to Step 3.
/// </summary>
private async Task GoFromUploadAsync()
{
if (_session is null)
{
// Peek the manifest to find out if it's encrypted. We re-call LoadAsync
// with null passphrase; for encrypted bundles this throws
// ArgumentException → advance to Step 2.
try
{
await TryLoadAsync(passphrase: null);
}
catch (ArgumentException)
{
_step = ImportWizardStep.Passphrase;
return;
}
catch (CryptographicException)
{
_errorMessage = "Bundle could not be decrypted.";
return;
}
}
if (_session is null)
{
// Some other error already surfaced via _errorMessage.
return;
}
if (_session.Manifest.Encryption is not null)
{
_step = ImportWizardStep.Passphrase;
}
else
{
await LoadPreviewAndAdvanceAsync();
}
}
// ============================================================
// Step 2 — Passphrase
// ============================================================
/// <summary>
/// Submits the entered passphrase. On <see cref="CryptographicException"/>
/// increments the per-session counter; once the configured threshold is
/// reached the wizard resets to Step 1 with an explanatory error.
/// </summary>
private async Task SubmitPassphraseAsync()
{
if (string.IsNullOrEmpty(_passphrase))
{
return;
}
_uploadInProgress = true;
try
{
await TryLoadAsync(_passphrase);
if (_session is not null)
{
_failedUnlockAttempts = 0;
_passphrase = string.Empty;
await LoadPreviewAndAdvanceAsync();
}
}
catch (CryptographicException)
{
_failedUnlockAttempts++;
_passphrase = string.Empty;
if (_failedUnlockAttempts >= Options.Value.MaxUnlockAttemptsPerSession)
{
_errorMessage =
$"Too many failed unlock attempts ({_failedUnlockAttempts}). "
+ "Please re-upload the bundle.";
ResetSessionState();
_step = ImportWizardStep.Upload;
}
else
{
_errorMessage = "Wrong passphrase. Please try again.";
}
}
catch (ArgumentException)
{
_errorMessage = "Passphrase required.";
}
finally
{
_uploadInProgress = false;
}
}
private void BackToUpload()
{
_step = ImportWizardStep.Upload;
_errorMessage = null;
}
// ============================================================
// Step 3 — Diff & resolve conflicts
// ============================================================
private async Task LoadPreviewAndAdvanceAsync()
{
if (_session is null) return;
try
{
_preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None);
_resolutions = BuildDefaultResolutions(_preview);
_step = ImportWizardStep.Diff;
}
catch (Exception ex)
{
_errorMessage = $"Failed to build import preview: {ex.Message}";
}
}
/// <summary>
/// Builds the default resolution per preview item:
/// <list type="bullet">
/// <item><see cref="ConflictKind.Identical"/> → <see cref="ResolutionAction.Skip"/></item>
/// <item><see cref="ConflictKind.New"/> → <see cref="ResolutionAction.Add"/></item>
/// <item><see cref="ConflictKind.Modified"/> → <see cref="ResolutionAction.Overwrite"/></item>
/// <item><see cref="ConflictKind.Blocker"/> → <see cref="ResolutionAction.Skip"/> (UI disables Apply anyway)</item>
/// </list>
/// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned.
/// </summary>
internal static Dictionary<(string EntityType, string Name), ImportResolution> BuildDefaultResolutions(
ImportPreview preview)
{
var map = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
{
ConflictKind.Identical => ResolutionAction.Skip,
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Modified => ResolutionAction.Overwrite,
ConflictKind.Blocker => ResolutionAction.Skip,
_ => ResolutionAction.Skip,
};
map[(item.EntityType, item.Name)] = new ImportResolution(
item.EntityType, item.Name, action, RenameTo: null);
}
return map;
}
private void SetResolution((string EntityType, string Name) key, ResolutionAction action)
{
if (_resolutions is null) return;
var existing = _resolutions[key];
_resolutions[key] = existing with { Action = action };
}
private void SetRenameTo((string EntityType, string Name) key, string? renameTo)
{
if (_resolutions is null) return;
var existing = _resolutions[key];
_resolutions[key] = existing with { RenameTo = renameTo };
}
private void BulkSet(ResolutionAction action)
{
if (_resolutions is null || _preview is null) return;
foreach (var item in _preview.Items)
{
if (item.Kind != ConflictKind.Modified) continue;
var key = (item.EntityType, item.Name);
_resolutions[key] = _resolutions[key] with { Action = action };
}
}
private (int Adds, int Overs, int Skips, int Renames, int Blockers) CountResolutions()
{
if (_preview is null || _resolutions is null) return (0, 0, 0, 0, 0);
var adds = 0;
var overs = 0;
var skips = 0;
var renames = 0;
var blockers = 0;
foreach (var item in _preview.Items)
{
if (item.Kind == ConflictKind.Blocker)
{
blockers++;
continue;
}
var action = _resolutions[(item.EntityType, item.Name)].Action;
switch (action)
{
case ResolutionAction.Add: adds++; break;
case ResolutionAction.Overwrite: overs++; break;
case ResolutionAction.Skip: skips++; break;
case ResolutionAction.Rename: renames++; break;
}
}
return (adds, overs, skips, renames, blockers);
}
private void GoToConfirm()
{
if (_preview is null) return;
if (_preview.Items.Any(i => i.Kind == ConflictKind.Blocker))
{
_errorMessage = "Cannot proceed while blockers exist — resolve or remove blocker rows first.";
return;
}
_confirmEnvironmentText = string.Empty;
_step = ImportWizardStep.Confirm;
}
private void BackToDiff()
{
_step = ImportWizardStep.Diff;
_errorMessage = null;
_validationErrors = null;
_result = null;
}
// ============================================================
// Step 4 + 5 — Confirm & Apply
// ============================================================
/// <summary>
/// Invokes <see cref="IBundleImporter.ApplyAsync"/> with the collected
/// resolutions and the authenticated user identity. Distinguishes
/// <see cref="SemanticValidationException"/> (recoverable — surface the
/// error list and let the operator return to Step 3) from generic
/// exceptions (display generic error + force re-upload).
/// </summary>
private async Task ApplyAsync()
{
if (_session is null || _resolutions is null) return;
if (_confirmEnvironmentText != _session.Manifest.SourceEnvironment) return;
_applyInProgress = true;
_errorMessage = null;
_validationErrors = null;
_result = null;
try
{
var user = await Auth.GetCurrentUsernameAsync();
_result = await BundleImporter.ApplyAsync(
_session.SessionId,
_resolutions.Values.ToList(),
user,
CancellationToken.None);
_step = ImportWizardStep.Result;
}
catch (SemanticValidationException ex)
{
_validationErrors = ex.Errors;
_step = ImportWizardStep.Result;
}
catch (Exception ex)
{
_errorMessage = $"Import failed: {ex.Message}. Please re-upload the bundle.";
_step = ImportWizardStep.Result;
}
finally
{
_applyInProgress = false;
}
}
// ============================================================
// Reset helpers
// ============================================================
private void ResetSessionState()
{
_session = null;
_bundleBytes = null;
_preview = null;
_resolutions = null;
_passphrase = string.Empty;
_confirmEnvironmentText = string.Empty;
_result = null;
_validationErrors = null;
}
}