Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor
T

570 lines
26 KiB
Plaintext

@page "/design/transport/import"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Options
@using ZB.MOM.WW.ScadaBridge.Transport
@using ZB.MOM.WW.ScadaBridge.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 (_bundleTempPath is not null && _errorMessage is null)
{
@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>
}
else
{
<div class="alert alert-info mt-3" data-testid="encrypted-bundle-notice">
<strong>Encrypted bundle uploaded.</strong>
Click <strong>Next</strong> to enter the passphrase.
</div>
}
<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>
@RenderMapSection();
<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))
{
var lineDiff = TryExtractLineDiff(item.FieldDiffJson);
<tr>
<td colspan="6">
<details>
<summary class="small">Field diff</summary>
@if (lineDiff is not null)
{
<div class="mt-2" data-testid="code-line-diff">
<div class="small text-body-secondary mb-1">Code changes</div>
<LineDiffView LineDiff="lineDiff" />
</div>
}
else
{
<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>
};
// ============================================================
// Step 3 — Map sub-section (M8 E2)
// ============================================================
// Shown only when the preview references source-environment sites/connections
// the operator must resolve before import. For central-config-only bundles the
// preview carries no required mappings and this renders nothing.
private RenderFragment RenderMapSection() => __builder =>
{
if (_preview is null) return;
var hasSiteMappings = _preview.RequiredSiteMappings.Count > 0;
var hasConnMappings = _preview.RequiredConnectionMappings.Count > 0;
if (!hasSiteMappings && !hasConnMappings)
{
return;
}
<div class="card mb-4" data-testid="map-section">
<div class="card-header bg-body-tertiary">
<strong>Resolve site &amp; connection references</strong>
<span class="small text-body-secondary ms-2">
This bundle references sites/connections from its source environment.
Map each to an existing target, or create a new one.
</span>
</div>
<div class="card-body">
@if (hasSiteMappings)
{
<h6 class="small text-uppercase text-body-secondary">Sites</h6>
<table class="table table-sm align-middle mb-4" data-testid="map-sites-table">
<thead>
<tr>
<th>Source identifier</th>
<th>Source name</th>
<th>Map to target</th>
</tr>
</thead>
<tbody>
@foreach (var rsm in _preview.RequiredSiteMappings)
{
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c) ? c : CreateNewValue;
<tr data-testid="map-site-row">
<td><code>@rsm.SourceSiteIdentifier</code></td>
<td>@rsm.SourceSiteName</td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
data-testid="@($"map-site-select-{rsm.SourceSiteIdentifier}")"
value="@chosen"
@onchange="e => OnSiteChoiceChangedAsync(rsm.SourceSiteIdentifier, e.Value?.ToString())">
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosen))">Create new</option>
@foreach (var site in _targetSites)
{
<option value="@site.SiteIdentifier" selected="@(chosen == site.SiteIdentifier)">
@site.Name (@site.SiteIdentifier)
</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
}
@if (hasConnMappings)
{
<h6 class="small text-uppercase text-body-secondary">Connections</h6>
@foreach (var grp in _preview.RequiredConnectionMappings.GroupBy(m => m.SourceSiteIdentifier))
{
var siteTarget = _siteChoices.TryGetValue(grp.Key, out var st) ? st : CreateNewValue;
var targetConns = ConnectionsForChosenTarget(grp.Key);
<div class="mb-3" data-testid="map-conn-group">
<div class="small text-body-secondary mb-1">
Site <code>@grp.Key</code>
@if (string.IsNullOrEmpty(siteTarget))
{
<span class="badge bg-success ms-1">new site</span>
}
</div>
<table class="table table-sm align-middle mb-0" data-testid="map-conns-table">
<thead>
<tr>
<th>Source connection</th>
<th>Map to target</th>
</tr>
</thead>
<tbody>
@foreach (var rcm in grp)
{
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
var chosenConn = _connectionChoices.TryGetValue(key, out var cc) ? cc : CreateNewValue;
<tr data-testid="map-conn-row">
<td>@rcm.SourceConnectionName</td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
data-testid="@($"map-conn-select-{rcm.SourceSiteIdentifier}-{rcm.SourceConnectionName}")"
value="@chosenConn"
@onchange="e => OnConnectionChoiceChanged(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, e.Value?.ToString())">
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosenConn))">Create new</option>
@foreach (var conn in targetConns)
{
<option value="@conn.Name" selected="@(chosenConn == conn.Name)">@conn.Name</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
</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>
};
}