Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs
T
Joseph Doherty f881521cc9 fix(transport-ui): reset connection choices on site change + clear state on back (M8 E2 review)
Fix 1: OnSiteChoiceChangedAsync now resets _connectionChoices for every
RequiredConnectionMapping under the changed source site after loading the new
target's connections. Choices are re-seeded to the same-named connection on
the new target if present, or CreateNewValue otherwise — preventing BuildNameMap
from emitting MapToExisting for a connection absent from the newly-chosen target.

Fix 2: BackToUpload now calls ResetSessionState() before resetting _step so
_session, _preview, _resolutions, _siteChoices, _connectionChoices,
_targetSites, and _targetConnections are all cleared when the operator backs
out to re-upload, making it safe to start a new import flow from a clean slate.

Tests 12 + 13 added to TransportImportPageTests.
2026-06-18 07:39:19 -04:00

864 lines
36 KiB
C#

using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
namespace ZB.MOM.WW.ScadaBridge.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. CentralUI-031: we previously cached the raw bytes in a
/// <c>byte[] _bundleBytes</c> field, which buffered the full upload (default cap
/// 100 MB) in the component's per-circuit state — multiplied across concurrent
/// operator sessions, that produced real central-node memory pressure. The
/// bytes are now streamed once to a per-session temp file under
/// <c>Path.GetTempPath()/scadabridge-transport-staging/</c> and only the path is
/// retained on the component. The file is deleted on Back-to-Upload / Reset /
/// successful Apply / component Dispose, so an abandoned wizard does not leak
/// staged bundle plaintext beyond circuit teardown.
/// </summary>
public partial class TransportImport : ComponentBase, IDisposable
{
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!;
[Inject] private IAuditService AuditService { get; set; } = default!;
[Inject] private ScadaBridgeDbContext DbContext { get; set; } = default!;
// M8 E2: the Map step needs the destination environment's sites + each
// site's connections to populate the "map to existing target" dropdowns.
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
// ---- Wizard state ----
private ImportWizardStep _step = ImportWizardStep.Upload;
private string? _errorMessage;
// ---- Session + cached bundle path ----
// CentralUI-031: the upload is streamed to a per-session temp file and only
// the path is retained on the component, so we don't hold an entire bundle
// (up to MaxBundleSizeMb, default 100 MB) in per-circuit memory across the
// wizard's lifetime. The file is deleted on every wizard reset path and on
// component disposal so an abandoned wizard cannot leak staged plaintext
// beyond circuit teardown.
private string? _bundleTempPath;
private BundleSession? _session;
private bool _uploadInProgress;
// Staging directory for in-flight bundle uploads. Lives under the system
// temp directory rather than wwwroot/ because the file is never served to
// a browser — it is only read by the in-process IBundleImporter.
private static readonly string StagingDir =
Path.Combine(Path.GetTempPath(), "scadabridge-transport-staging");
// ---- 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 3 (Map sub-section, M8 E2): name mapping ----
// The sentinel dropdown value for "Create new" — empty string can't collide
// with a real SiteIdentifier / connection Name (both are non-empty).
private const string CreateNewValue = "";
// Destination environment's sites, loaded once when the preview carries any
// required mappings.
private IReadOnlyList<Site> _targetSites = Array.Empty<Site>();
// Per-target-site connections, loaded lazily and cached by SiteIdentifier.
private readonly Dictionary<string, IReadOnlyList<DataConnection>> _targetConnections = new();
// Operator's site choices, keyed by SourceSiteIdentifier. Value is the chosen
// target SiteIdentifier, or CreateNewValue ("") for "Create new". Seeded from
// each RequiredSiteMapping.AutoMatchTargetIdentifier.
private readonly Dictionary<string, string> _siteChoices = new();
// Operator's connection choices, keyed by (SourceSiteIdentifier, SourceConnectionName).
// Value is the chosen target connection Name, or CreateNewValue for "Create new".
private readonly Dictionary<(string SourceSite, string SourceConn), string> _connectionChoices = new();
// ---- 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;
DeleteBundleTempFile();
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;
}
// CentralUI-031: stream the upload directly to a per-session temp
// file so the central node's working set is bounded by the
// FileStream buffer (~80 KB) rather than the full bundle bytes.
// 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.
Directory.CreateDirectory(StagingDir);
_bundleTempPath = Path.Combine(StagingDir, $"{Guid.NewGuid():N}.scadabundle");
using (var fileStream = e.File.OpenReadStream(maxBytes))
await using (var dest = new FileStream(
_bundleTempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
{
await fileStream.CopyToAsync(dest);
}
await TryLoadAsync(passphrase: null);
}
catch (ArgumentException)
{
// Encrypted bundle, no passphrase yet — expected. The wizard
// advances to the passphrase step when the user clicks Next.
}
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 (_bundleTempPath is null || !File.Exists(_bundleTempPath))
{
_errorMessage = "No bundle staged — please re-select the file.";
return;
}
try
{
// CentralUI-031: read the staged bundle straight off disk; the
// importer's LoadAsync only walks the stream forward, so a plain
// FileStream is sufficient (no need to buffer it back into memory).
using var stream = new FileStream(
_bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 81920, useAsync: true);
_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.
/// <para>
/// T-003: lockout enforcement is now server-side and keyed by the bundle's
/// content hash. <see cref="CryptographicException"/> means "wrong passphrase,
/// try again"; a <see cref="BundleLockedException"/> means the importer has
/// observed enough failures against this bundle to lock it (the count is
/// shared across tabs / CLI / circuits). The Razor counter is kept ONLY for
/// display ("3 of N attempts used") — it is no longer the source of truth.
/// </para>
/// </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 (BundleLockedException ex)
{
// T-003: server-side lockout reached. Emit a final audit row so the
// lockout is visible in the audit log, reset the wizard, and surface
// the typed message verbatim.
_passphrase = string.Empty;
_failedUnlockAttempts = ex.FailedAttempts;
await EmitUnlockFailedAuditRowAsync(ex.BundleContentHash, ex.FailedAttempts, ex.Message);
_errorMessage = ex.Message;
ResetSessionState();
_step = ImportWizardStep.Upload;
}
catch (CryptographicException ex)
{
_failedUnlockAttempts++;
_passphrase = string.Empty;
var entityId = _session?.Manifest.ContentHash ?? "<no-session>";
await EmitUnlockFailedAuditRowAsync(entityId, _failedUnlockAttempts, ex.Message);
// The server tracks the authoritative counter; the local count is
// kept in sync for the Razor display only.
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;
}
}
/// <summary>
/// T-003: best-effort audit row for a wrong-passphrase attempt. Audit failure
/// must never abort the user-facing action — same defensive pattern as the
/// original page used.
/// </summary>
private async Task EmitUnlockFailedAuditRowAsync(string entityId, int attemptNumber, string reason)
{
try
{
var user = await Auth.GetCurrentUsernameAsync();
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
await AuditService.LogAsync(
user: user,
action: "BundleImportUnlockFailed",
entityType: "Bundle",
entityId: entityId,
entityName: entityName,
afterState: new
{
AttemptNumber = attemptNumber,
Reason = reason,
},
cancellationToken: CancellationToken.None);
await DbContext.SaveChangesAsync();
}
catch
{
// Audit failure is non-fatal — swallow and continue.
}
}
private void BackToUpload()
{
// Reset all wizard + Map state so a subsequent re-upload starts from a
// clean slate and cannot observe stale session / preview / name-map data
// from the previous flow. ResetSessionState also clears _preview,
// _resolutions, _siteChoices, _connectionChoices, etc.
ResetSessionState();
_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);
await InitNameMappingStateAsync(_preview);
_step = ImportWizardStep.Diff;
}
catch (Exception ex)
{
_errorMessage = $"Failed to build import preview: {ex.Message}";
}
}
/// <summary>
/// M8 E2: prepares the Map sub-section state for a freshly loaded preview.
/// When the preview carries no required site/connection mappings this is a
/// no-op (the Map section is hidden) and we skip the site/connection reads
/// entirely — central-config-only bundles never touch the destination's
/// site catalogue.
///
/// <para>Default choices follow the reference precedence: each site/connection
/// is seeded to its <c>AutoMatch*</c> value when the importer found a match,
/// otherwise to "Create new". The operator's explicit dropdown choice then
/// wins over the auto-match default.</para>
/// </summary>
private async Task InitNameMappingStateAsync(ImportPreview preview)
{
_targetSites = Array.Empty<Site>();
_targetConnections.Clear();
_siteChoices.Clear();
_connectionChoices.Clear();
if (preview.RequiredSiteMappings.Count == 0 && preview.RequiredConnectionMappings.Count == 0)
{
return;
}
_targetSites = await SiteRepo.GetAllSitesAsync(CancellationToken.None);
foreach (var rsm in preview.RequiredSiteMappings)
{
// Seed to the auto-matched target identifier when present AND that
// target still exists in the destination; otherwise "Create new".
var seed = rsm.AutoMatchTargetIdentifier is not null
&& _targetSites.Any(s => s.SiteIdentifier == rsm.AutoMatchTargetIdentifier)
? rsm.AutoMatchTargetIdentifier
: CreateNewValue;
_siteChoices[rsm.SourceSiteIdentifier] = seed;
// Eagerly load the seeded target's connections so the connection-row
// dropdowns beneath this site can render their option sets without an
// async hop on first render.
if (!string.IsNullOrEmpty(seed) && !_targetConnections.ContainsKey(seed))
{
var target = _targetSites.First(s => s.SiteIdentifier == seed);
_targetConnections[seed] =
await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
}
}
foreach (var rcm in preview.RequiredConnectionMappings)
{
// Default the connection to its auto-match. We don't pre-validate it
// against the chosen target's connection list here — the dropdown's
// option set (driven by the chosen site) governs what's selectable,
// and the seed simply renders as the initial selection.
_connectionChoices[(rcm.SourceSiteIdentifier, rcm.SourceConnectionName)] =
rcm.AutoMatchTargetName ?? CreateNewValue;
}
}
/// <summary>
/// Returns the connections of the destination site the operator chose for
/// <paramref name="sourceSiteIdentifier"/> (used to populate a connection
/// row's dropdown). Reads the <c>_targetConnections</c> cache, which is
/// populated for every chosen target site by <see cref="InitNameMappingStateAsync"/>
/// (seeded auto-matches) and <see cref="OnSiteChoiceChangedAsync"/> (operator
/// changes). Returns an empty list when the source site maps to "Create new"
/// (no existing target connections to bind to).
/// </summary>
private IReadOnlyList<DataConnection> ConnectionsForChosenTarget(string sourceSiteIdentifier)
{
if (!_siteChoices.TryGetValue(sourceSiteIdentifier, out var targetSiteIdentifier)
|| string.IsNullOrEmpty(targetSiteIdentifier))
{
return Array.Empty<DataConnection>();
}
return _targetConnections.TryGetValue(targetSiteIdentifier, out var cached)
? cached
: Array.Empty<DataConnection>();
}
/// <summary>
/// Handles a site-mapping dropdown change. Records the choice, eagerly loads +
/// caches the new target's connections, then resets the per-connection choices
/// for every <see cref="RequiredConnectionMapping"/> under this source site so
/// that stale selections from the previous target cannot survive into
/// <see cref="BuildNameMap"/>. Each connection is re-seeded: if the new target
/// has a same-named connection the choice defaults to that name (matching the
/// auto-match logic in <see cref="InitNameMappingStateAsync"/>); otherwise it
/// falls back to the <see cref="CreateNewValue"/> sentinel ("Create new").
/// </summary>
private async Task OnSiteChoiceChangedAsync(string sourceSiteIdentifier, string? value)
{
var chosen = value ?? CreateNewValue;
_siteChoices[sourceSiteIdentifier] = chosen;
if (!string.IsNullOrEmpty(chosen) && !_targetConnections.ContainsKey(chosen))
{
var target = _targetSites.FirstOrDefault(s => s.SiteIdentifier == chosen);
if (target is not null)
{
_targetConnections[chosen] =
await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
}
}
// Reset connection choices for every RequiredConnectionMapping belonging
// to this source site. The previously-chosen connection name may not exist
// on the newly-chosen target site, and leaving it would cause BuildNameMap
// to emit a MapToExisting for a connection that does not exist there.
if (_preview is not null)
{
foreach (var rcm in _preview.RequiredConnectionMappings
.Where(m => m.SourceSiteIdentifier == sourceSiteIdentifier))
{
// Default to the new target's same-named connection when present;
// otherwise fall back to Create-new.
_connectionChoices[(sourceSiteIdentifier, rcm.SourceConnectionName)] =
(!string.IsNullOrEmpty(chosen)
&& _targetConnections.TryGetValue(chosen, out var conns)
&& conns.Any(c => c.Name == rcm.SourceConnectionName))
? rcm.SourceConnectionName
: CreateNewValue;
}
}
}
private void OnConnectionChoiceChanged(string sourceSiteIdentifier, string sourceConnectionName, string? value)
{
_connectionChoices[(sourceSiteIdentifier, sourceConnectionName)] = value ?? CreateNewValue;
}
/// <summary>
/// M8 E2: folds the operator's Map-step choices into a <see cref="BundleNameMap"/>.
/// A concrete chosen target → <see cref="MappingAction.MapToExisting"/> with that
/// target identifier/name; the "Create new" sentinel → <see cref="MappingAction.CreateNew"/>
/// with a null target. Iterates the preview's required mappings so the map's shape
/// always matches what the bundle references (a missing choice falls back to its
/// auto-match default, mirroring the seed). Returns <see cref="BundleNameMap.Empty"/>
/// when the preview carries no required mappings.
/// </summary>
private BundleNameMap BuildNameMap()
{
if (_preview is null
|| (_preview.RequiredSiteMappings.Count == 0 && _preview.RequiredConnectionMappings.Count == 0))
{
return BundleNameMap.Empty;
}
var sites = new List<SiteMapping>(_preview.RequiredSiteMappings.Count);
foreach (var rsm in _preview.RequiredSiteMappings)
{
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c)
? c
: (rsm.AutoMatchTargetIdentifier ?? CreateNewValue);
sites.Add(string.IsNullOrEmpty(chosen)
? new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.CreateNew, null)
: new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.MapToExisting, chosen));
}
var connections = new List<ConnectionMapping>(_preview.RequiredConnectionMappings.Count);
foreach (var rcm in _preview.RequiredConnectionMappings)
{
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
var chosen = _connectionChoices.TryGetValue(key, out var c)
? c
: (rcm.AutoMatchTargetName ?? CreateNewValue);
connections.Add(string.IsNullOrEmpty(chosen)
? new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.CreateNew, null)
: new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.MapToExisting, chosen));
}
return new BundleNameMap(sites, connections);
}
/// <summary>
/// M8 E2: parses a Modified item's <c>FieldDiffJson</c> and returns the
/// <c>lineDiff</c> object for the first code field that carries one, or null
/// when the diff has no line-level payload (ordinary fields render as a coarse
/// summary instead). Tolerant of malformed/absent JSON — a parse failure
/// simply yields null so the row degrades to the coarse field-diff view.
/// </summary>
internal static JsonElement? TryExtractLineDiff(string? fieldDiffJson)
{
if (string.IsNullOrWhiteSpace(fieldDiffJson))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(fieldDiffJson);
if (!doc.RootElement.TryGetProperty("changes", out var changes)
|| changes.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var change in changes.EnumerateArray())
{
if (change.ValueKind == JsonValueKind.Object
&& change.TryGetProperty("lineDiff", out var lineDiff)
&& lineDiff.ValueKind == JsonValueKind.Object)
{
// Clone so the element outlives the JsonDocument we dispose.
return lineDiff.Clone();
}
}
return null;
}
catch (JsonException)
{
return null;
}
}
/// <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>
/// <param name="preview">The import preview containing all conflict items to map.</param>
/// <returns>A dictionary keyed by (EntityType, Name) with default resolution actions populated.</returns>
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();
// M8 E2: fold the operator's Map-step choices into the name map. For
// central-config-only bundles (no required mappings) this is
// BundleNameMap.Empty, which the importer normalises away.
var nameMap = BuildNameMap();
_result = await BundleImporter.ApplyAsync(
_session.SessionId,
_resolutions.Values.ToList(),
user,
CancellationToken.None,
nameMap: nameMap);
_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;
DeleteBundleTempFile();
_preview = null;
_resolutions = null;
_passphrase = string.Empty;
_confirmEnvironmentText = string.Empty;
_result = null;
_validationErrors = null;
// M8 E2: clear the Map sub-section state too.
_targetSites = Array.Empty<Site>();
_targetConnections.Clear();
_siteChoices.Clear();
_connectionChoices.Clear();
}
/// <summary>
/// CentralUI-031: deletes the staged bundle temp file if any. Swallows IO
/// failures — an undeletable temp file is best-effort cleanup and must not
/// block the wizard.
/// </summary>
private void DeleteBundleTempFile()
{
var path = _bundleTempPath;
_bundleTempPath = null;
if (path is null) return;
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Another handle may still be open (e.g. an in-flight LoadAsync
// read). Leave the file behind; the OS temp dir is reaped on its
// own schedule. Audit-failure-style: never block the user-facing
// action.
}
catch (UnauthorizedAccessException)
{
// Same rationale.
}
}
/// <summary>
/// CentralUI-031: ensures the staged temp file does not survive circuit
/// teardown. Blazor invokes Dispose when the user navigates away or the
/// circuit ends, so an abandoned wizard cleans up automatically.
/// </summary>
public void Dispose()
{
DeleteBundleTempFile();
}
}