refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
@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>
|
||||
<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>
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user