570 lines
26 KiB
Plaintext
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 & 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>
|
|
};
|
|
}
|