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,343 @@
|
||||
@page "/design/api-methods/create"
|
||||
@page "/design/api-methods/{Id:int}/edit"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">← Back</button>
|
||||
|
||||
<h4 class="mb-3">@(Id.HasValue ? "Edit API Method" : "Add API Method")</h4>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" @bind="_name" disabled="@Id.HasValue" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Approved API Keys</label>
|
||||
@if (_allKeys.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No API keys configured.
|
||||
<a href="/admin/api-keys">Create one</a> to authorize callers for this method.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var key in _allKeys)
|
||||
{
|
||||
var checkboxId = $"approved-key-{key.Id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedKeyIds.Contains(key.Id)"
|
||||
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Callers must present a checked key in the <code>X-API-Key</code> header to invoke this method.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Parameters</label>
|
||||
<SchemaBuilder Mode="object"
|
||||
Value="@_params"
|
||||
ValueChanged="@(v => _params = v)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Return value</label>
|
||||
<SchemaBuilder Mode="value"
|
||||
Value="@_returns"
|
||||
ValueChanged="@(v => _returns = v)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Script</label>
|
||||
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
|
||||
Language="csharp" Height="320px"
|
||||
ScriptKind="ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
|
||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
|
||||
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||
</div>
|
||||
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<button class="btn btn-outline-primary" @onclick="ToggleTestRunPanel">
|
||||
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showTestRun)
|
||||
{
|
||||
<div class="card mt-3" id="test-run-panel">
|
||||
<div class="card-header py-2">
|
||||
<span class="fw-semibold">Test Run</span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||
<strong>Heads up:</strong>
|
||||
runs the script as typed (unsaved edits included) against the supplied
|
||||
<code>Parameters</code>. <code>Route</code> calls throw — cross-site
|
||||
routing needs a deployed site reachable over the cluster transport.
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameter values</label>
|
||||
<ParameterValueForm ParameterDefinitions="@_params"
|
||||
Values="_paramValues"
|
||||
ValuesChanged="@(v => _paramValues = v)" />
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
|
||||
@if (_running)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span>Running…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Run</span>
|
||||
}
|
||||
</button>
|
||||
@if (_runResult != null)
|
||||
{
|
||||
<span class="text-muted small">@_runResult.DurationMs ms</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_runResult != null)
|
||||
{
|
||||
@if (_runResult.Success)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-success mb-1">
|
||||
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
|
||||
</label>
|
||||
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-danger mb-1">
|
||||
<span class="badge bg-danger me-1">@ErrorKindLabel(_runResult.ErrorKind)</span>
|
||||
</label>
|
||||
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
|
||||
@if (_runResult.Markers is { Count: > 0 })
|
||||
{
|
||||
<ul class="small text-danger mt-2 mb-0">
|
||||
@foreach (var m in _runResult.Markers)
|
||||
{
|
||||
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
|
||||
{
|
||||
<div class="mb-0">
|
||||
<label class="form-label small mb-1">Console output</label>
|
||||
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private string _name = "", _script = "";
|
||||
private int _timeoutSeconds = 30;
|
||||
private string? _params, _returns;
|
||||
private string? _formError;
|
||||
private MonacoEditor? _editor;
|
||||
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
|
||||
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
private ApiMethod? _existing;
|
||||
private List<ApiKey> _allKeys = new();
|
||||
private HashSet<int> _selectedKeyIds = new();
|
||||
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
private Dictionary<string, object?> _paramValues = new();
|
||||
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||
private CancellationTokenSource? _runCts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_allKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
|
||||
if (Id.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
_existing = await InboundApiRepository.GetApiMethodByIdAsync(Id.Value);
|
||||
if (_existing != null)
|
||||
{
|
||||
_name = _existing.Name;
|
||||
_script = _existing.Script;
|
||||
_timeoutSeconds = _existing.TimeoutSeconds;
|
||||
_params = _existing.ParameterDefinitions;
|
||||
_returns = _existing.ReturnDefinition;
|
||||
_selectedKeyIds = ParseApprovedKeyIds(_existing.ApprovedApiKeyIds);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseApprovedKeyIds(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<int>();
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private void ToggleKey(int keyId, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedKeyIds.Add(keyId);
|
||||
else _selectedKeyIds.Remove(keyId);
|
||||
}
|
||||
|
||||
private string? SerializeApprovedKeyIds() =>
|
||||
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_script))
|
||||
{
|
||||
_formError = "Name and script required.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var approvedKeyIds = SerializeApprovedKeyIds();
|
||||
if (_existing != null)
|
||||
{
|
||||
_existing.Script = _script;
|
||||
_existing.TimeoutSeconds = _timeoutSeconds;
|
||||
_existing.ParameterDefinitions = _params?.Trim();
|
||||
_existing.ReturnDefinition = _returns?.Trim();
|
||||
_existing.ApprovedApiKeyIds = approvedKeyIds;
|
||||
await InboundApiRepository.UpdateApiMethodAsync(_existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var m = new ApiMethod(_name.Trim(), _script)
|
||||
{
|
||||
TimeoutSeconds = _timeoutSeconds,
|
||||
ParameterDefinitions = _params?.Trim(),
|
||||
ReturnDefinition = _returns?.Trim(),
|
||||
ApprovedApiKeyIds = approvedKeyIds
|
||||
};
|
||||
await InboundApiRepository.AddApiMethodAsync(m);
|
||||
}
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/design/external-systems");
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
||||
|
||||
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
|
||||
|
||||
private async Task RunInSandboxAsync()
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
_runCts = new CancellationTokenSource();
|
||||
_running = true;
|
||||
_runResult = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var jsonParams = _paramValues.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
|
||||
var request = new ScriptAnalysis.SandboxRunRequest(
|
||||
_script, jsonParams, TimeoutSeconds: _timeoutSeconds,
|
||||
Kind: ScriptAnalysis.ScriptKind.InboundApi);
|
||||
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded by next Run click */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_runResult = new ScriptAnalysis.SandboxRunResult(
|
||||
Success: false,
|
||||
ReturnValueJson: null,
|
||||
ReturnTypeName: null,
|
||||
ConsoleOutput: "",
|
||||
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
|
||||
ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError,
|
||||
DurationMs: 0,
|
||||
Markers: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch
|
||||
{
|
||||
ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
|
||||
ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
|
||||
ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
|
||||
ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
|
||||
_ => "Error"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
@page "/design/connections/create"
|
||||
@page "/design/connections/{Id:int}/edit"
|
||||
@page "/design/data-connections/create"
|
||||
@page "/design/data-connections/{Id:int}/edit"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Serialization
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Validators
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Forms
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back</button>
|
||||
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Site</label>
|
||||
@if (_siteLocked)
|
||||
{
|
||||
<input type="text"
|
||||
class="form-control form-control-plaintext form-control-sm"
|
||||
readonly
|
||||
value="@_siteName" />
|
||||
<div class="form-text">Site is locked after creation.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<select class="form-select form-select-sm" @bind="_formSiteId">
|
||||
<option value="0">Select site...</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.Id">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
|
||||
<h6 class="text-muted mt-3">Primary endpoint</h6>
|
||||
<OpcUaEndpointEditor Title="Primary Endpoint"
|
||||
IdPrefix="primary"
|
||||
Config="_primaryConfig"
|
||||
IsLegacy="_primaryIsLegacy"
|
||||
Errors="_primaryErrors" />
|
||||
|
||||
<h6 class="text-muted mt-3">
|
||||
Backup endpoint
|
||||
@if (!_showBackup)
|
||||
{
|
||||
<span class="badge bg-light text-muted border ms-2">Optional</span>
|
||||
}
|
||||
</h6>
|
||||
@if (!_showBackup)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@onclick="EnableBackup">Add Backup Endpoint</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<OpcUaEndpointEditor Title="Backup Endpoint"
|
||||
IdPrefix="backup"
|
||||
Config="_backupConfig"
|
||||
IsLegacy="_backupIsLegacy"
|
||||
Errors="_backupErrors" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Failover Retry Count</label>
|
||||
<input type="number" class="form-control form-control-sm" style="max-width: 120px;"
|
||||
min="1" max="20" @bind="_formFailoverRetryCount" />
|
||||
<div class="form-text">Retries before failing over to backup endpoint.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
@onclick="RemoveBackup">Remove Backup</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
[SupplyParameterFromQuery] public int? SiteId { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private DataConnection? _editingConnection;
|
||||
private List<Site> _sites = new();
|
||||
private int _formSiteId;
|
||||
private string _siteName = string.Empty;
|
||||
private bool _siteLocked;
|
||||
private string _formName = string.Empty;
|
||||
private OpcUaEndpointConfig _primaryConfig = new();
|
||||
private OpcUaEndpointConfig _backupConfig = new();
|
||||
private bool _primaryIsLegacy;
|
||||
private bool _backupIsLegacy;
|
||||
private bool _showBackup;
|
||||
private int _formFailoverRetryCount = 3;
|
||||
private ValidationResult? _primaryErrors;
|
||||
private ValidationResult? _backupErrors;
|
||||
private string? _formError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
|
||||
if (Id.HasValue)
|
||||
{
|
||||
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
|
||||
if (_editingConnection != null)
|
||||
{
|
||||
_formSiteId = _editingConnection.SiteId;
|
||||
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
|
||||
_siteLocked = true;
|
||||
_formName = _editingConnection.Name;
|
||||
|
||||
(_primaryConfig, _primaryIsLegacy) =
|
||||
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration))
|
||||
{
|
||||
(_backupConfig, _backupIsLegacy) =
|
||||
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration);
|
||||
_showBackup = true;
|
||||
_formFailoverRetryCount = _editingConnection.FailoverRetryCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (SiteId.HasValue)
|
||||
{
|
||||
var site = _sites.FirstOrDefault(s => s.Id == SiteId.Value);
|
||||
if (site != null)
|
||||
{
|
||||
_formSiteId = site.Id;
|
||||
_siteName = site.Name;
|
||||
_siteLocked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveConnection()
|
||||
{
|
||||
_formError = null;
|
||||
if (_formSiteId == 0) { _formError = "Site is required."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
|
||||
_backupErrors = _showBackup
|
||||
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
|
||||
: null;
|
||||
|
||||
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
|
||||
{
|
||||
_formError = "Fix the errors below before saving.";
|
||||
return;
|
||||
}
|
||||
|
||||
var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
|
||||
var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingConnection != null)
|
||||
{
|
||||
_editingConnection.Name = _formName.Trim();
|
||||
_editingConnection.Protocol = "OpcUa";
|
||||
_editingConnection.PrimaryConfiguration = primaryJson;
|
||||
_editingConnection.BackupConfiguration = backupJson;
|
||||
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
|
||||
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
|
||||
}
|
||||
else
|
||||
{
|
||||
var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId)
|
||||
{
|
||||
PrimaryConfiguration = primaryJson,
|
||||
BackupConfiguration = backupJson,
|
||||
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
|
||||
};
|
||||
await SiteRepository.AddDataConnectionAsync(conn);
|
||||
}
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/design/connections");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void EnableBackup() => _showBackup = true;
|
||||
|
||||
private void RemoveBackup()
|
||||
{
|
||||
_showBackup = false;
|
||||
_backupConfig = new OpcUaEndpointConfig();
|
||||
_backupIsLegacy = false;
|
||||
_formFailoverRetryCount = 3;
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/connections");
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
@page "/design/connections"
|
||||
@page "/design/data-connections"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Connections</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
||||
data-bs-toggle="dropdown">
|
||||
Bulk actions
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => _tree?.ExpandAll()">
|
||||
Expand all
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => _tree?.CollapseAll()">
|
||||
Collapse all
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
disabled="@(!HasSiteSelected)"
|
||||
@onclick="OnAddConnectionClicked">+ Connection</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Search sites or connections..."
|
||||
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_searchText) && _matchKeys.Count == 0 && _treeRoots.Count > 0)
|
||||
{
|
||||
<p class="text-muted small">No connections match the filter.</p>
|
||||
}
|
||||
|
||||
<TreeView @ref="_tree" TItem="DcTreeNode" Items="_treeRoots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => (object)n.Key"
|
||||
StorageKey="data-connections-tree"
|
||||
Selectable="true"
|
||||
SelectedKey="_selectedKey"
|
||||
SelectedKeyChanged="OnTreeNodeSelected">
|
||||
<NodeContent Context="node">
|
||||
@{
|
||||
var labelStyle = IsDimmed(node) ? "opacity: 0.4;" : "";
|
||||
}
|
||||
@if (node.Kind == DcNodeKind.Site)
|
||||
{
|
||||
<span class="tv-label fw-semibold" style="@labelStyle">@node.Label</span>
|
||||
<span class="badge bg-secondary ms-1">@node.Children.Count</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="tv-label" style="@labelStyle">@node.Label</span>
|
||||
<span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
|
||||
}
|
||||
<span class="tv-meta">
|
||||
<div class="dropdown dc-node-actions" @onclick:stopPropagation="true">
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm p-0 dc-kebab"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="@($"More actions for {node.Label}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@if (node.Kind == DcNodeKind.Site)
|
||||
{
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
@onclick="() => AddConnectionForSite(node.SiteId!.Value)">
|
||||
Add Connection here
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
|
||||
Edit
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick="() => DeleteConnection(node.Connection!)">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</span>
|
||||
</NodeContent>
|
||||
<ContextMenu Context="node">
|
||||
@if (node.Kind == DcNodeKind.Site)
|
||||
{
|
||||
<button class="dropdown-item"
|
||||
@onclick="() => AddConnectionForSite(node.SiteId!.Value)">
|
||||
Add Connection here
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
|
||||
Edit
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick="() => DeleteConnection(node.Connection!)">
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</ContextMenu>
|
||||
<EmptyContent>
|
||||
<span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
|
||||
</EmptyContent>
|
||||
</TreeView>
|
||||
|
||||
<div class="text-muted small mt-2">
|
||||
@_connections.Count connection(s) across @_treeRoots.Count site(s).
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Kebab visible-on-hover for tree nodes; always visible at small sizes for touch. */
|
||||
.dc-node-actions .dc-kebab {
|
||||
opacity: 0;
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem !important;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.tv-row:hover .dc-node-actions .dc-kebab,
|
||||
.dc-node-actions.show .dc-kebab,
|
||||
.dc-node-actions .dc-kebab:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
@@media (max-width: 768px) {
|
||||
.dc-node-actions .dc-kebab { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
record DcTreeNode(string Key, string Label, DcNodeKind Kind, List<DcTreeNode> Children,
|
||||
int? SiteId = null, DataConnection? Connection = null);
|
||||
enum DcNodeKind { Site, DataConnection }
|
||||
|
||||
private List<DcTreeNode> _treeRoots = new();
|
||||
private List<DataConnection> _connections = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
private TreeView<DcTreeNode>? _tree;
|
||||
private object? _selectedKey;
|
||||
private string _searchText = string.Empty;
|
||||
private HashSet<string> _matchKeys = new();
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
private bool HasSiteSelected => ResolveSelectedSiteId() != null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
var sites = await SiteRepository.GetAllSitesAsync();
|
||||
_connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
|
||||
|
||||
var connBySite = _connections.GroupBy(c => c.SiteId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
_treeRoots = sites.Select(site => new DcTreeNode(
|
||||
Key: $"site-{site.Id}",
|
||||
Label: site.Name,
|
||||
Kind: DcNodeKind.Site,
|
||||
Children: (connBySite.GetValueOrDefault(site.Id) ?? new())
|
||||
.Select(c => new DcTreeNode(
|
||||
Key: $"conn-{c.Id}",
|
||||
Label: c.Name,
|
||||
Kind: DcNodeKind.DataConnection,
|
||||
Children: new(),
|
||||
SiteId: c.SiteId,
|
||||
Connection: c))
|
||||
.ToList(),
|
||||
SiteId: site.Id
|
||||
)).ToList();
|
||||
RebuildMatchKeys();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load data: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void OnTreeNodeSelected(object? key)
|
||||
{
|
||||
_selectedKey = key;
|
||||
}
|
||||
|
||||
private int? ResolveSelectedSiteId()
|
||||
{
|
||||
if (_selectedKey is not string keyStr) return null;
|
||||
foreach (var site in _treeRoots)
|
||||
{
|
||||
if (site.Key == keyStr) return site.SiteId;
|
||||
foreach (var child in site.Children)
|
||||
{
|
||||
if (child.Key == keyStr) return site.SiteId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void OnAddConnectionClicked()
|
||||
{
|
||||
var sid = ResolveSelectedSiteId();
|
||||
if (sid == null) return;
|
||||
AddConnectionForSite(sid.Value);
|
||||
}
|
||||
|
||||
private void AddConnectionForSite(int siteId)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/design/connections/create?siteId={siteId}");
|
||||
}
|
||||
|
||||
private void OnSearchChanged()
|
||||
{
|
||||
RebuildMatchKeys();
|
||||
}
|
||||
|
||||
private void RebuildMatchKeys()
|
||||
{
|
||||
_matchKeys.Clear();
|
||||
if (string.IsNullOrWhiteSpace(_searchText)) return;
|
||||
var q = _searchText.Trim();
|
||||
foreach (var root in _treeRoots)
|
||||
{
|
||||
SubtreeContainsMatch(root, q);
|
||||
}
|
||||
}
|
||||
|
||||
private bool SubtreeContainsMatch(DcTreeNode node, string query)
|
||||
{
|
||||
var selfMatch = node.Label.Contains(query, StringComparison.OrdinalIgnoreCase);
|
||||
var childMatch = false;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
if (SubtreeContainsMatch(child, query)) childMatch = true;
|
||||
}
|
||||
if (selfMatch || childMatch) _matchKeys.Add(node.Key);
|
||||
return selfMatch || childMatch;
|
||||
}
|
||||
|
||||
private bool IsDimmed(DcTreeNode node)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_searchText)) return false;
|
||||
return !_matchKeys.Contains(node.Key);
|
||||
}
|
||||
|
||||
private async Task DeleteConnection(DataConnection conn)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete Connection",
|
||||
$"Delete data connection '{conn.Name}'?",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await SiteRepository.DeleteDataConnectionAsync(conn.Id);
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"Connection '{conn.Name}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
@page "/design/db-connections/create"
|
||||
@page "/design/db-connections/{Id:int}/edit"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IExternalSystemRepository ExternalSystemRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">← Back</button>
|
||||
|
||||
<h4 class="mb-3">@(Id.HasValue ? "Edit Database Connection" : "Add Database Connection")</h4>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" @bind="_name" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Connection String</label>
|
||||
<input type="text" class="form-control" @bind="_connectionString" />
|
||||
<div class="form-text">Treat as sensitive — visible to admins only.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max Retries</label>
|
||||
<input type="number" class="form-control" @bind="_maxRetries" min="0" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Retry Delay (seconds)</label>
|
||||
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
|
||||
</div>
|
||||
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private string _name = "", _connectionString = "";
|
||||
private int _maxRetries = 3;
|
||||
private int _retryDelaySeconds = 5;
|
||||
private string? _formError;
|
||||
|
||||
private DatabaseConnectionDefinition? _existing;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (Id.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
_existing = await ExternalSystemRepository.GetDatabaseConnectionByIdAsync(Id.Value);
|
||||
if (_existing != null)
|
||||
{
|
||||
_name = _existing.Name;
|
||||
_connectionString = _existing.ConnectionString;
|
||||
_maxRetries = _existing.MaxRetries;
|
||||
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_connectionString))
|
||||
{
|
||||
_formError = "Name and connection string required.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_existing != null)
|
||||
{
|
||||
_existing.Name = _name.Trim();
|
||||
_existing.ConnectionString = _connectionString.Trim();
|
||||
_existing.MaxRetries = _maxRetries;
|
||||
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
|
||||
await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var dc = new DatabaseConnectionDefinition(_name.Trim(), _connectionString.Trim())
|
||||
{
|
||||
MaxRetries = _maxRetries,
|
||||
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
|
||||
};
|
||||
await ExternalSystemRepository.AddDatabaseConnectionAsync(dc);
|
||||
}
|
||||
await ExternalSystemRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/design/external-systems");
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
@page "/design/external-systems/create"
|
||||
@page "/design/external-systems/{Id:int}/edit"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IExternalSystemRepository ExternalSystemRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">← Back</button>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(Id.HasValue ? "Edit External System" : "Add External System")</h4>
|
||||
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log
|
||||
pre-filtered to this external system's outbound API events. Audit rows
|
||||
record the target by external-system name, so we filter on Target. *@
|
||||
@if (Id.HasValue && !string.IsNullOrWhiteSpace(_name))
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="/audit/log?target=@Uri.EscapeDataString(_name)"
|
||||
data-test="audit-link">
|
||||
Recent audit activity
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" @bind="_name" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Endpoint URL</label>
|
||||
<input type="text" class="form-control" @bind="_endpointUrl" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Auth Type</label>
|
||||
<select class="form-select" @bind="_authType">
|
||||
<option>ApiKey</option>
|
||||
<option>BasicAuth</option>
|
||||
<option>None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Auth Config (JSON)</label>
|
||||
<input type="text" class="form-control" @bind="_authConfig"
|
||||
placeholder="@_authConfigPlaceholder" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max Retries</label>
|
||||
<input type="number" class="form-control" @bind="_maxRetries" min="0" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Retry Delay (seconds)</label>
|
||||
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
|
||||
</div>
|
||||
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private string _name = "", _endpointUrl = "", _authType = "ApiKey";
|
||||
private string? _authConfig;
|
||||
private int _maxRetries = 3;
|
||||
private int _retryDelaySeconds = 5;
|
||||
private string? _formError;
|
||||
|
||||
private ExternalSystemDefinition? _existing;
|
||||
|
||||
// Per-AuthType placeholder that hints at the expected JSON shape for AuthConfig.
|
||||
private string _authConfigPlaceholder => _authType switch
|
||||
{
|
||||
"ApiKey" => "{\"key\":\"xyz\"}",
|
||||
"BasicAuth" => "{\"username\":\"u\",\"password\":\"p\"}",
|
||||
_ => "{}"
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (Id.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
_existing = await ExternalSystemRepository.GetExternalSystemByIdAsync(Id.Value);
|
||||
if (_existing != null)
|
||||
{
|
||||
_name = _existing.Name;
|
||||
_endpointUrl = _existing.EndpointUrl;
|
||||
_authType = _existing.AuthType;
|
||||
_authConfig = _existing.AuthConfiguration;
|
||||
_maxRetries = _existing.MaxRetries;
|
||||
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_endpointUrl))
|
||||
{
|
||||
_formError = "Name and URL required.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_existing != null)
|
||||
{
|
||||
_existing.Name = _name.Trim();
|
||||
_existing.EndpointUrl = _endpointUrl.Trim();
|
||||
_existing.AuthType = _authType;
|
||||
_existing.AuthConfiguration = _authConfig?.Trim();
|
||||
_existing.MaxRetries = _maxRetries;
|
||||
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
|
||||
await ExternalSystemRepository.UpdateExternalSystemAsync(_existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var es = new ExternalSystemDefinition(_name.Trim(), _endpointUrl.Trim(), _authType)
|
||||
{
|
||||
AuthConfiguration = _authConfig?.Trim(),
|
||||
MaxRetries = _maxRetries,
|
||||
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
|
||||
};
|
||||
await ExternalSystemRepository.AddExternalSystemAsync(es);
|
||||
}
|
||||
await ExternalSystemRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/design/external-systems");
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
@page "/design/external-systems"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IExternalSystemRepository ExternalSystemRepository
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Integration Definitions</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(_tab == "extsys" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_tab == "extsys" ? "true" : "false")"
|
||||
aria-controls="int-tab-extsys"
|
||||
@onclick='() => _tab = "extsys"'>
|
||||
External Systems <span class="badge bg-secondary">@_externalSystems.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(_tab == "dbconn" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_tab == "dbconn" ? "true" : "false")"
|
||||
aria-controls="int-tab-dbconn"
|
||||
@onclick='() => _tab = "dbconn"'>
|
||||
Database Connections <span class="badge bg-secondary">@_dbConnections.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(_tab == "inbound" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_tab == "inbound" ? "true" : "false")"
|
||||
aria-controls="int-tab-inbound"
|
||||
@onclick='() => _tab = "inbound"'>
|
||||
Inbound API Methods <span class="badge bg-secondary">@_apiMethods.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (_tab == "extsys")
|
||||
{
|
||||
<div role="tabpanel" id="int-tab-extsys">@RenderExternalSystems()</div>
|
||||
}
|
||||
else if (_tab == "dbconn")
|
||||
{
|
||||
<div role="tabpanel" id="int-tab-dbconn">@RenderDbConnections()</div>
|
||||
}
|
||||
else if (_tab == "inbound")
|
||||
{
|
||||
<div role="tabpanel" id="int-tab-inbound">@RenderInboundApiMethods()</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private string _tab = "extsys";
|
||||
|
||||
// External Systems
|
||||
private List<ExternalSystemDefinition> _externalSystems = new();
|
||||
private string _extsysSearch = "";
|
||||
private IEnumerable<ExternalSystemDefinition> FilteredExternalSystems =>
|
||||
string.IsNullOrWhiteSpace(_extsysSearch)
|
||||
? _externalSystems
|
||||
: _externalSystems.Where(es => es.Name?.Contains(_extsysSearch, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
// Database Connections
|
||||
private List<DatabaseConnectionDefinition> _dbConnections = new();
|
||||
private string _dbConnSearch = "";
|
||||
private IEnumerable<DatabaseConnectionDefinition> FilteredDbConnections =>
|
||||
string.IsNullOrWhiteSpace(_dbConnSearch)
|
||||
? _dbConnections
|
||||
: _dbConnections.Where(dc => dc.Name?.Contains(_dbConnSearch, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
// Inbound API Methods
|
||||
private List<ApiMethod> _apiMethods = new();
|
||||
private string _apiMethodSearch = "";
|
||||
private IEnumerable<ApiMethod> FilteredApiMethods =>
|
||||
string.IsNullOrWhiteSpace(_apiMethodSearch)
|
||||
? _apiMethods
|
||||
: _apiMethods.Where(m => m.Name?.Contains(_apiMethodSearch, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAllAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAllAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_externalSystems = (await ExternalSystemRepository.GetAllExternalSystemsAsync()).ToList();
|
||||
_dbConnections = (await ExternalSystemRepository.GetAllDatabaseConnectionsAsync()).ToList();
|
||||
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex) { _errorMessage = ex.Message; }
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
// ==== External Systems ====
|
||||
private RenderFragment RenderExternalSystems() => __builder =>
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0">External Systems</h5>
|
||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>Add External System</button>
|
||||
</div>
|
||||
|
||||
@if (_externalSystems.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-3">No external systems configured.</p>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>
|
||||
Add your first external system
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="Filter by name…"
|
||||
@bind="_extsysSearch" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (!FilteredExternalSystems.Any())
|
||||
{
|
||||
<p class="text-muted small">No external systems match the filter.</p>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
@foreach (var es in FilteredExternalSystems)
|
||||
{
|
||||
<div class="col-lg-6 col-12" @key="es.Id">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">@es.Name</h5>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/external-systems/{es.Id}/edit")'>Edit</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="@($"More actions for {es.Name}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><button class="dropdown-item text-danger" @onclick="() => DeleteExtSys(es)">Delete</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small text-muted text-truncate mb-1" title="@es.EndpointUrl">@es.EndpointUrl</p>
|
||||
<div>
|
||||
<span class="badge bg-secondary me-1">@es.AuthType</span>
|
||||
<span class="badge bg-light text-dark me-1">Max @es.MaxRetries retries</span>
|
||||
<span class="badge bg-light text-dark">Delay @es.RetryDelay.TotalSeconds s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
private async Task DeleteExtSys(ExternalSystemDefinition es)
|
||||
{
|
||||
if (!await Dialog.ConfirmAsync("Delete External System", $"Delete '{es.Name}'?", danger: true)) return;
|
||||
try { await ExternalSystemRepository.DeleteExternalSystemAsync(es.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
|
||||
catch (Exception ex) { _toast.ShowError(ex.Message); }
|
||||
}
|
||||
|
||||
// ==== Database Connections ====
|
||||
private RenderFragment RenderDbConnections() => __builder =>
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0">Database Connections</h5>
|
||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>Add Database Connection</button>
|
||||
</div>
|
||||
|
||||
@if (_dbConnections.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-3">No database connections configured.</p>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>
|
||||
Add your first database connection
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="Filter by name…"
|
||||
@bind="_dbConnSearch" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (!FilteredDbConnections.Any())
|
||||
{
|
||||
<p class="text-muted small">No database connections match the filter.</p>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
@foreach (var dc in FilteredDbConnections)
|
||||
{
|
||||
<div class="col-lg-6 col-12" @key="dc.Id">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">@dc.Name</h5>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/db-connections/{dc.Id}/edit")'>Edit</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="@($"More actions for {dc.Name}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><button class="dropdown-item text-danger" @onclick="() => DeleteDbConn(dc)">Delete</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small text-muted text-truncate mb-1" title="@dc.ConnectionString">@dc.ConnectionString</p>
|
||||
<div>
|
||||
<span class="badge bg-light text-dark me-1">Max @dc.MaxRetries retries</span>
|
||||
<span class="badge bg-light text-dark">Delay @dc.RetryDelay.TotalSeconds s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
private async Task DeleteDbConn(DatabaseConnectionDefinition dc)
|
||||
{
|
||||
if (!await Dialog.ConfirmAsync("Delete DB Connection", $"Delete '{dc.Name}'?", danger: true)) return;
|
||||
try { await ExternalSystemRepository.DeleteDatabaseConnectionAsync(dc.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
|
||||
catch (Exception ex) { _toast.ShowError(ex.Message); }
|
||||
}
|
||||
|
||||
// ==== Inbound API Methods ====
|
||||
private RenderFragment RenderInboundApiMethods() => __builder =>
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0">Inbound API Methods</h5>
|
||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>Add API Method</button>
|
||||
</div>
|
||||
|
||||
@if (_apiMethods.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-3">No API methods configured.</p>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>
|
||||
Add your first API method
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="Filter by name…"
|
||||
@bind="_apiMethodSearch" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (!FilteredApiMethods.Any())
|
||||
{
|
||||
<p class="text-muted small">No API methods match the filter.</p>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
@foreach (var m in FilteredApiMethods)
|
||||
{
|
||||
var preview = m.Script.Length > 80 ? m.Script[..80] + "…" : m.Script;
|
||||
<div class="col-lg-6 col-12" @key="m.Id">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">@m.Name</h5>
|
||||
<code class="small">POST /api/@m.Name</code>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/api-methods/{m.Id}/edit")'>Edit</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="@($"More actions for {m.Name}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><button class="dropdown-item text-danger" @onclick="() => DeleteApiMethod(m)">Delete</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="small text-muted font-monospace mb-2"
|
||||
style="white-space: pre-wrap; word-break: break-all;"
|
||||
title="@m.Script">@preview</pre>
|
||||
<span class="badge bg-light text-dark">Timeout @m.TimeoutSeconds s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
private async Task DeleteApiMethod(ApiMethod m)
|
||||
{
|
||||
if (!await Dialog.ConfirmAsync("Delete", $"Delete API method '{m.Name}'?", danger: true)) return;
|
||||
try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
|
||||
catch (Exception ex) { _toast.ShowError(ex.Message); }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
@page "/design/shared-scripts/create"
|
||||
@page "/design/shared-scripts/{Id:int}/edit"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
|
||||
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject SharedScriptService SharedScriptService
|
||||
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back</button>
|
||||
<h4 class="mb-0">@(Id.HasValue ? $"Edit Shared Script: {_formName}" : "New Shared Script")</h4>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName"
|
||||
disabled="@(Id.HasValue)" />
|
||||
</div>
|
||||
|
||||
@* Tabs: Code, Parameters, Return. Panels stay mounted (toggled
|
||||
via display:none) so the Monaco editor and the JSONJoy React
|
||||
island don't tear down on tab switch. *@
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button type="button"
|
||||
class="nav-link @(_formTab == "code" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_formTab == "code" ? "true" : "false")"
|
||||
@onclick='() => _formTab = "code"'>Code</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button type="button"
|
||||
class="nav-link @(_formTab == "parameters" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_formTab == "parameters" ? "true" : "false")"
|
||||
@onclick='() => _formTab = "parameters"'>Parameters</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button type="button"
|
||||
class="nav-link @(_formTab == "return" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_formTab == "return" ? "true" : "false")"
|
||||
@onclick='() => _formTab = "return"'>Return type</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="border border-top-0 rounded-bottom p-3">
|
||||
<div style="display: @(_formTab == "code" ? "block" : "none")">
|
||||
<MonacoEditor @ref="_editor" Value="@_formCode" ValueChanged="@(v => _formCode = v)"
|
||||
Language="csharp" Height="320px"
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_formParameters)"
|
||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_formParameters)"
|
||||
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||
</div>
|
||||
<div style="display: @(_formTab == "parameters" ? "block" : "none")">
|
||||
<SchemaBuilder Mode="object"
|
||||
Value="@_formParameters"
|
||||
ValueChanged="@(v => _formParameters = v)" />
|
||||
</div>
|
||||
<div style="display: @(_formTab == "return" ? "block" : "none")">
|
||||
<SchemaBuilder Mode="value"
|
||||
Value="@_formReturn"
|
||||
ValueChanged="@(v => _formReturn = v)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
@if (_syntaxCheckResult != null)
|
||||
{
|
||||
<div class="@(_syntaxCheckPassed ? "text-success" : "text-danger") small mt-1">@_syntaxCheckResult</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
|
||||
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
|
||||
<button class="btn btn-outline-primary btn-sm me-1" @onclick="ToggleTestRunPanel">
|
||||
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showTestRun)
|
||||
{
|
||||
<div class="card mb-3" id="test-run-panel">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||
<strong>Heads up:</strong>
|
||||
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
|
||||
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
|
||||
<code>Attributes</code> and <code>CallScript</code> still throw.
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameter values</label>
|
||||
<ParameterValueForm ParameterDefinitions="@_formParameters"
|
||||
Values="_paramValues"
|
||||
ValuesChanged="@(v => _paramValues = v)" />
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
|
||||
@if (_running)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span>Running…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Run</span>
|
||||
}
|
||||
</button>
|
||||
@if (_runResult != null)
|
||||
{
|
||||
<span class="text-muted small">@_runResult.DurationMs ms</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_runResult != null)
|
||||
{
|
||||
@if (_runResult.Success)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-success mb-1">
|
||||
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
|
||||
</label>
|
||||
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-danger mb-1">
|
||||
<span class="badge bg-danger me-1">@ErrorKindLabel(_runResult.ErrorKind)</span>
|
||||
</label>
|
||||
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
|
||||
@if (_runResult.Markers is { Count: > 0 })
|
||||
{
|
||||
<ul class="small text-danger mt-2 mb-0">
|
||||
@foreach (var m in _runResult.Markers)
|
||||
{
|
||||
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
|
||||
{
|
||||
<div class="mb-0">
|
||||
<label class="form-label small mb-1">Console output</label>
|
||||
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
|
||||
private bool _loading;
|
||||
private string _formTab = "code"; // "code" | "parameters" | "return"
|
||||
private string _formName = string.Empty;
|
||||
private string _formCode = string.Empty;
|
||||
private string? _formParameters;
|
||||
private string? _formReturn;
|
||||
private string? _formError;
|
||||
private string? _syntaxCheckResult;
|
||||
private bool _syntaxCheckPassed;
|
||||
private MonacoEditor? _editor;
|
||||
private IReadOnlyList<ScriptAnalysis.DiagnosticMarker> _markers = Array.Empty<ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
private Dictionary<string, object?> _paramValues = new();
|
||||
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||
private CancellationTokenSource? _runCts;
|
||||
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (Id.HasValue)
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
var scripts = await SharedScriptService.GetAllSharedScriptsAsync();
|
||||
var script = scripts.FirstOrDefault(s => s.Id == Id.Value);
|
||||
if (script != null)
|
||||
{
|
||||
_formName = script.Name;
|
||||
_formCode = script.Code;
|
||||
_formParameters = script.ParameterDefinitions;
|
||||
_formReturn = script.ReturnDefinition;
|
||||
}
|
||||
else
|
||||
{
|
||||
_formError = $"Shared script with ID {Id.Value} not found.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Failed to load script: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
NavigationManager.NavigateTo("/design/shared-scripts");
|
||||
}
|
||||
|
||||
private void CheckCompilation()
|
||||
{
|
||||
var syntaxError = ValidateSyntaxLocally(_formCode);
|
||||
if (syntaxError == null)
|
||||
{
|
||||
_syntaxCheckResult = "Syntax check passed.";
|
||||
_syntaxCheckPassed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_syntaxCheckResult = syntaxError;
|
||||
_syntaxCheckPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveScript()
|
||||
{
|
||||
_formError = null;
|
||||
_syntaxCheckResult = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (Id.HasValue)
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await SharedScriptService.UpdateSharedScriptAsync(
|
||||
Id.Value, _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/design/shared-scripts");
|
||||
}
|
||||
else
|
||||
{
|
||||
_formError = result.Error;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await SharedScriptService.CreateSharedScriptAsync(
|
||||
_formName.Trim(), _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/design/shared-scripts");
|
||||
}
|
||||
else
|
||||
{
|
||||
_formError = result.Error;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleTestRunPanel()
|
||||
{
|
||||
_showTestRun = !_showTestRun;
|
||||
}
|
||||
|
||||
private async Task RunInSandboxAsync()
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
_runCts = new CancellationTokenSource();
|
||||
_running = true;
|
||||
_runResult = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var jsonParams = _paramValues.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
|
||||
var request = new ScriptAnalysis.SandboxRunRequest(_formCode, jsonParams, TimeoutSeconds: null);
|
||||
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded by next Run click */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_runResult = new ScriptAnalysis.SandboxRunResult(
|
||||
Success: false,
|
||||
ReturnValueJson: null,
|
||||
ReturnTypeName: null,
|
||||
ConsoleOutput: "",
|
||||
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
|
||||
ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError,
|
||||
DurationMs: 0,
|
||||
Markers: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch
|
||||
{
|
||||
ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
|
||||
ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
|
||||
ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
|
||||
ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
|
||||
_ => "Error"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Basic syntax check: balanced braces/brackets/parens.
|
||||
/// Mirrors the internal SharedScriptService.ValidateSyntax logic.
|
||||
/// </summary>
|
||||
private static string? ValidateSyntaxLocally(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code)) return "Script code cannot be empty.";
|
||||
int brace = 0, bracket = 0, paren = 0;
|
||||
foreach (var ch in code)
|
||||
{
|
||||
switch (ch) { case '{': brace++; break; case '}': brace--; break; case '[': bracket++; break; case ']': bracket--; break; case '(': paren++; break; case ')': paren--; break; }
|
||||
if (brace < 0) return "Syntax error: unmatched closing brace '}'.";
|
||||
if (bracket < 0) return "Syntax error: unmatched closing bracket ']'.";
|
||||
if (paren < 0) return "Syntax error: unmatched closing parenthesis ')'.";
|
||||
}
|
||||
if (brace != 0) return "Syntax error: unmatched opening brace '{'.";
|
||||
if (bracket != 0) return "Syntax error: unmatched opening bracket '['.";
|
||||
if (paren != 0) return "Syntax error: unmatched opening parenthesis '('.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
@page "/design/shared-scripts"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject SharedScriptService SharedScriptService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Shared Scripts</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/shared-scripts/create")'>New Script</button>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else if (_scripts.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-3">No shared scripts configured.</p>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo("/design/shared-scripts/create")'>
|
||||
Create your first script
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="Filter by name or code…"
|
||||
@bind="_search" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (!FilteredScripts.Any())
|
||||
{
|
||||
<p class="text-muted small">No shared scripts match the filter.</p>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
@foreach (var s in FilteredScripts)
|
||||
{
|
||||
var preview = s.Code.Length > 80
|
||||
? s.Code[..80] + "…"
|
||||
: s.Code;
|
||||
var paramCount = CountSchemaProperties(s.ParameterDefinitions);
|
||||
var returnLabel = DescribeReturnType(s.ReturnDefinition);
|
||||
<div class="col-lg-6 col-12" @key="s.Id">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">@s.Name</h5>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{s.Id}/edit")'>
|
||||
Edit
|
||||
</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="@($"More actions for {s.Name}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick="() => DeleteScript(s)">Delete</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre class="small text-muted font-monospace mb-2"
|
||||
style="white-space: pre-wrap; word-break: break-all;"
|
||||
title="@s.Code">@preview</pre>
|
||||
|
||||
<div>
|
||||
<span class="badge bg-light text-dark me-1">@paramCount params</span>
|
||||
<span class="badge bg-light text-dark">
|
||||
@(returnLabel == "void" ? "void" : $"returns {returnLabel}")
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
|
||||
private List<SharedScript> _scripts = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private string _search = "";
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
private IEnumerable<SharedScript> FilteredScripts =>
|
||||
string.IsNullOrWhiteSpace(_search)
|
||||
? _scripts
|
||||
: _scripts.Where(s =>
|
||||
(s.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(s.Code?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_scripts = (await SharedScriptService.GetAllSharedScriptsAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load shared scripts: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task DeleteScript(SharedScript script)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete Shared Script",
|
||||
$"Delete shared script '{script.Name}'?",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await SharedScriptService.DeleteSharedScriptAsync(script.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Script '{script.Name}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError(result.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the parameters declared in either format: a JSON Schema object
|
||||
/// (<c>{"type":"object","properties":{...}}</c>) or the legacy flat array.
|
||||
/// Returns 0 for null/empty/malformed input.
|
||||
/// </summary>
|
||||
private static int CountSchemaProperties(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return 0;
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& root.TryGetProperty("properties", out var props)
|
||||
&& props.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
return props.EnumerateObject().Count();
|
||||
}
|
||||
if (root.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
return root.GetArrayLength();
|
||||
}
|
||||
catch { /* fall through */ }
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces a short human label for a script's return type from its JSON
|
||||
/// Schema definition: "string", "integer", "object", "string[]", etc.
|
||||
/// Treats null/empty/malformed input as "void".
|
||||
/// </summary>
|
||||
private static string DescribeReturnType(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return "void";
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return "void";
|
||||
if (!root.TryGetProperty("type", out var typeEl)) return "object";
|
||||
var type = typeEl.GetString() ?? "object";
|
||||
if (type == "array"
|
||||
&& root.TryGetProperty("items", out var items)
|
||||
&& items.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& items.TryGetProperty("type", out var itemTypeEl))
|
||||
{
|
||||
return $"{itemTypeEl.GetString() ?? "object"}[]";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
catch { return "void"; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
@page "/design/templates/create"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject TemplateService TemplateService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
aria-label="Back to Templates"
|
||||
@onclick="GoBack">← Back</button>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Create Template</h4>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" @bind="_createName" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Parent Template</label>
|
||||
<select class="form-select" @bind="_createParentId">
|
||||
<option value="0">(None - root template)</option>
|
||||
@foreach (var t in _templates)
|
||||
{
|
||||
<option value="@t.Id">@t.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-control" @bind="_createDescription" />
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success me-1" @onclick="CreateTemplate">Create</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery] public int? FolderId { get; set; }
|
||||
[SupplyParameterFromQuery] public int? ParentId { get; set; }
|
||||
|
||||
private List<Template> _templates = new();
|
||||
private bool _loading = true;
|
||||
|
||||
private string _createName = string.Empty;
|
||||
private int _createParentId;
|
||||
private string? _createDescription;
|
||||
private string? _formError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||
if (ParentId is int pid && _templates.Any(t => t.Id == pid))
|
||||
{
|
||||
_createParentId = pid;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Failed to load templates: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task CreateTemplate()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Name is required."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateService.CreateTemplateAsync(
|
||||
_createName.Trim(), _createDescription?.Trim(),
|
||||
_createParentId == 0 ? null : _createParentId, user,
|
||||
folderId: FolderId);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/design/templates/{result.Value.Id}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_formError = result.Error;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
NavigationManager.NavigateTo("/design/templates");
|
||||
}
|
||||
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
||||
@page "/design/templates"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject TemplateService TemplateService
|
||||
@inject TemplateFolderService TemplateFolderService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
<RenameFolderDialog @bind-IsVisible="_showRenameFolderDialog"
|
||||
FolderId="_renameFolderId"
|
||||
InitialName="@_renameFolderInitialName"
|
||||
ErrorMessage="@_renameFolderError"
|
||||
OnSubmit="SubmitRenameFolder" />
|
||||
|
||||
<MoveTemplateDialog @bind-IsVisible="_showMoveTemplateDialog"
|
||||
TemplateId="_moveTemplateId"
|
||||
TemplateName="@_moveTemplateName"
|
||||
FolderOptions="EnumerateFolderOptions()"
|
||||
ErrorMessage="@_moveTemplateError"
|
||||
OnSubmit="SubmitMoveTemplate" />
|
||||
|
||||
<MoveFolderDialog @bind-IsVisible="_showMoveFolderDialog"
|
||||
FolderId="_moveFolderId"
|
||||
FolderName="@_moveFolderName"
|
||||
FolderOptions="EnumerateFolderOptionsExcluding(_moveFolderId)"
|
||||
ErrorMessage="@_moveFolderError"
|
||||
OnSubmit="SubmitMoveFolder" />
|
||||
|
||||
<ComposeIntoDialog @bind-IsVisible="_showComposeDialog"
|
||||
SourceTemplateId="_composeSourceId"
|
||||
SourceName="@_composeSourceName"
|
||||
ParentOptions="EnumerateComposableParents(_composeSourceId)"
|
||||
ErrorMessage="@_composeError"
|
||||
OnSubmit="SubmitCompose" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Templates</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Bulk actions
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => _tree.ExpandAll()">Expand all folders</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => _tree.CollapseAll()">Collapse all folders</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
title="New folder at root"
|
||||
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
title="New template at root"
|
||||
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
|
||||
<TemplateFolderTree @ref="_tree"
|
||||
Folders="_folders"
|
||||
Templates="_templates"
|
||||
SelectionMode="TreeViewSelectionMode.Single"
|
||||
ExtraTemplateChildren="BuildCompositionLeavesFor"
|
||||
StorageKey="templates-tree">
|
||||
<NodeContent Context="node">
|
||||
@RenderNodeLabel(node)
|
||||
</NodeContent>
|
||||
<ContextMenu Context="node">
|
||||
@RenderNodeContextMenu(node)
|
||||
</ContextMenu>
|
||||
<EmptyContent>
|
||||
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
|
||||
</EmptyContent>
|
||||
</TemplateFolderTree>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// CentralUI-024: delegates to the shared helper so the claim type stays
|
||||
// resolved through JwtTokenService rather than a duplicated magic string.
|
||||
private Task<string> GetCurrentUserAsync()
|
||||
=> AuthStateProvider.GetCurrentUsernameAsync();
|
||||
|
||||
private List<Template> _templates = new();
|
||||
private List<TemplateFolder> _folders = new();
|
||||
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
|
||||
private async Task LoadTemplatesAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
|
||||
_templatesById = _templates.ToDictionary(t => t.Id);
|
||||
_compositionsById = _templates
|
||||
.SelectMany(t => t.Compositions)
|
||||
.GroupBy(c => c.Id)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load templates: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
// ID lookups so RenderNodeLabel / RenderNodeContextMenu can resolve the
|
||||
// entity behind a TemplateTreeNode (whose payload is just Id+Kind+Name).
|
||||
private Dictionary<int, Template> _templatesById = new();
|
||||
private Dictionary<int, TemplateComposition> _compositionsById = new();
|
||||
|
||||
// Composition-leaf builder for TemplateFolderTree's ExtraTemplateChildren
|
||||
// hook: walks each template's compositions recursively so cascaded slots
|
||||
// appear as nested children. The Transport Export wizard intentionally
|
||||
// does NOT supply this hook — compositions aren't independently exportable.
|
||||
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(Template owner)
|
||||
{
|
||||
var result = new List<TemplateTreeNode>();
|
||||
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var node = new TemplateTreeNode
|
||||
{
|
||||
Kind = TemplateTreeNodeKind.Composition,
|
||||
Id = c.Id,
|
||||
Name = c.InstanceName,
|
||||
};
|
||||
|
||||
if (_templatesById.TryGetValue(c.ComposedTemplateId, out var composed))
|
||||
{
|
||||
foreach (var nested in BuildCompositionLeavesFor(composed))
|
||||
{
|
||||
node.Children.Add(nested);
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(node);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private TemplateFolderTree _tree = default!;
|
||||
|
||||
private void OpenTemplate(int templateId) =>
|
||||
NavigationManager.NavigateTo($"/design/templates/{templateId}");
|
||||
|
||||
private RenderFragment RenderNodeLabel(TemplateTreeNode node) => __builder =>
|
||||
{
|
||||
switch (node.Kind)
|
||||
{
|
||||
case TemplateTreeNodeKind.Folder:
|
||||
<span class="tv-glyph"><i class="bi bi-folder"></i></span>
|
||||
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||
title="@node.Name">@node.Name</span>
|
||||
@if (node.Children.Count > 0)
|
||||
{
|
||||
<span class="tv-meta">
|
||||
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
|
||||
</span>
|
||||
}
|
||||
break;
|
||||
|
||||
case TemplateTreeNodeKind.Template:
|
||||
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
|
||||
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||
title="@node.Name"
|
||||
@ondblclick="() => OpenTemplate(node.Id)">@node.Name</span>
|
||||
break;
|
||||
|
||||
case TemplateTreeNodeKind.Composition:
|
||||
var composedId = _compositionsById.TryGetValue(node.Id, out var comp) ? comp.ComposedTemplateId : 0;
|
||||
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
||||
<span class="tv-label" title="@node.Name"
|
||||
@ondblclick="() => OpenTemplate(composedId)">@node.Name</span>
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private RenderFragment RenderNodeContextMenu(TemplateTreeNode node) => __builder =>
|
||||
{
|
||||
switch (node.Kind)
|
||||
{
|
||||
case TemplateTreeNodeKind.Folder:
|
||||
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.Id)">New Folder</button>
|
||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.Id}")'>New Template</button>
|
||||
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.Id, node.Name)">Rename</button>
|
||||
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.Id, node.Name)">Move to Folder…</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.Id, node.Name)">Delete</button>
|
||||
break;
|
||||
|
||||
case TemplateTreeNodeKind.Template:
|
||||
var tmpl = _templatesById.TryGetValue(node.Id, out var t) ? t : null;
|
||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.Id}")'>New Inheriting Template</button>
|
||||
@if (tmpl != null)
|
||||
{
|
||||
<button class="dropdown-item" @onclick="() => OpenComposeDialog(tmpl)">Compose into…</button>
|
||||
}
|
||||
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.Id, node.Name)">Move to Folder…</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
@if (tmpl != null)
|
||||
{
|
||||
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(tmpl)">Delete</button>
|
||||
}
|
||||
break;
|
||||
|
||||
case TemplateTreeNodeKind.Composition:
|
||||
if (_compositionsById.TryGetValue(node.Id, out var ctx))
|
||||
{
|
||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{ctx.ComposedTemplateId}")'>Open composed template</button>
|
||||
<button class="dropdown-item" @onclick="() => RenameComposition(ctx)">Rename…</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(ctx)">Delete</button>
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// New-folder dialog: replaced the dedicated <NewFolderDialog> component with
|
||||
// IDialogService.PromptAsync. Validation failures surface via toast instead of
|
||||
// inline error text — the prompt UI doesn't have a slot for an error message.
|
||||
private async Task OpenNewFolderDialog(int? parentFolderId)
|
||||
{
|
||||
var name = await Dialog.PromptAsync("New folder", "Folder name", placeholder: "Folder name");
|
||||
if (string.IsNullOrWhiteSpace(name)) return;
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateFolderService.CreateFolderAsync(name.Trim(), parentFolderId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Folder '{result.Value.Name}' created.");
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// Move-template dialog state
|
||||
private bool _showMoveTemplateDialog;
|
||||
private int _moveTemplateId;
|
||||
private string _moveTemplateName = string.Empty;
|
||||
private string? _moveTemplateError;
|
||||
|
||||
private void OpenMoveTemplateDialog(int templateId, string label)
|
||||
{
|
||||
_moveTemplateId = templateId;
|
||||
_moveTemplateName = label;
|
||||
_moveTemplateError = null;
|
||||
_showMoveTemplateDialog = true;
|
||||
}
|
||||
|
||||
private async Task SubmitMoveTemplate((int TemplateId, int? NewFolderId) req)
|
||||
{
|
||||
_moveTemplateError = null;
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateService.MoveTemplateAsync(req.TemplateId, req.NewFolderId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_showMoveTemplateDialog = false;
|
||||
_toast.ShowSuccess($"Template '{_moveTemplateName}' moved.");
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_moveTemplateError = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat list of folders with indentation labels, for the picker.
|
||||
private IEnumerable<(int? Id, string Label)> EnumerateFolderOptions()
|
||||
{
|
||||
yield return (null, "(Root)");
|
||||
foreach (var f in WalkFolderHierarchy(_folders.Where(f => f.ParentFolderId == null), 0, excludeFolderId: null))
|
||||
yield return f;
|
||||
}
|
||||
|
||||
// Same as EnumerateFolderOptions, but prunes the given folder and all its descendants
|
||||
// so the move dialog can't surface an obvious cycle target (server still validates).
|
||||
private IEnumerable<(int? Id, string Label)> EnumerateFolderOptionsExcluding(int excludeFolderId)
|
||||
{
|
||||
yield return (null, "(Root)");
|
||||
foreach (var f in WalkFolderHierarchy(_folders.Where(f => f.ParentFolderId == null), 0, excludeFolderId))
|
||||
yield return f;
|
||||
}
|
||||
|
||||
private IEnumerable<(int? Id, string Label)> WalkFolderHierarchy(IEnumerable<TemplateFolder> level, int depth, int? excludeFolderId)
|
||||
{
|
||||
foreach (var f in level.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (excludeFolderId.HasValue && f.Id == excludeFolderId.Value) continue;
|
||||
yield return ((int?)f.Id, new string(' ', depth * 2) + f.Name);
|
||||
foreach (var sub in WalkFolderHierarchy(_folders.Where(c => c.ParentFolderId == f.Id), depth + 1, excludeFolderId))
|
||||
yield return sub;
|
||||
}
|
||||
}
|
||||
|
||||
// Move-folder dialog state
|
||||
private bool _showMoveFolderDialog;
|
||||
private int _moveFolderId;
|
||||
private string _moveFolderName = string.Empty;
|
||||
private string? _moveFolderError;
|
||||
|
||||
private void OpenMoveFolderDialog(int folderId, string label)
|
||||
{
|
||||
_moveFolderId = folderId;
|
||||
_moveFolderName = label;
|
||||
_moveFolderError = null;
|
||||
_showMoveFolderDialog = true;
|
||||
}
|
||||
|
||||
private async Task SubmitMoveFolder((int FolderId, int? NewParentId) req)
|
||||
{
|
||||
_moveFolderError = null;
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateFolderService.MoveFolderAsync(req.FolderId, req.NewParentId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_showMoveFolderDialog = false;
|
||||
_toast.ShowSuccess($"Folder '{_moveFolderName}' moved.");
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_moveFolderError = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
// Rename folder dialog state
|
||||
private bool _showRenameFolderDialog;
|
||||
private int _renameFolderId;
|
||||
private string _renameFolderInitialName = string.Empty;
|
||||
private string? _renameFolderError;
|
||||
|
||||
private void OpenRenameFolderDialog(int folderId, string currentName)
|
||||
{
|
||||
_renameFolderId = folderId;
|
||||
_renameFolderInitialName = currentName;
|
||||
_renameFolderError = null;
|
||||
_showRenameFolderDialog = true;
|
||||
}
|
||||
|
||||
private async Task SubmitRenameFolder((int FolderId, string NewName) req)
|
||||
{
|
||||
_renameFolderError = null;
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateFolderService.RenameFolderAsync(req.FolderId, req.NewName, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_showRenameFolderDialog = false;
|
||||
_toast.ShowSuccess("Folder renamed.");
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_renameFolderError = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteFolder(int folderId, string label)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync("Delete Folder", $"Delete folder '{label}'?", danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateFolderService.DeleteFolderAsync(folderId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Folder '{label}' deleted.");
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteTemplate(Template template)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete Template",
|
||||
$"Delete template '{template.Name}'? This will fail if instances or child templates reference it.",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateService.DeleteTemplateAsync(template.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Template '{template.Name}' deleted.");
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError(result.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Compose-into dialog ----
|
||||
private bool _showComposeDialog;
|
||||
private int _composeSourceId;
|
||||
private string _composeSourceName = string.Empty;
|
||||
private string? _composeError;
|
||||
|
||||
private void OpenComposeDialog(Template source)
|
||||
{
|
||||
_composeSourceId = source.Id;
|
||||
_composeSourceName = source.Name;
|
||||
_composeError = null;
|
||||
_showComposeDialog = true;
|
||||
}
|
||||
|
||||
// Possible parents for a compose: every non-derived template except the source itself.
|
||||
// Server still validates cycles + collisions; the picker just trims obvious bad choices.
|
||||
private IEnumerable<(int Id, string Label)> EnumerateComposableParents(int sourceId)
|
||||
{
|
||||
return _templates
|
||||
.Where(t => !t.IsDerived && t.Id != sourceId)
|
||||
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(t => (t.Id, t.Name));
|
||||
}
|
||||
|
||||
private async Task SubmitCompose((int SourceTemplateId, int ParentTemplateId, string SlotName) req)
|
||||
{
|
||||
_composeError = null;
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateService.AddCompositionAsync(req.ParentTemplateId, req.SourceTemplateId, req.SlotName, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_showComposeDialog = false;
|
||||
_toast.ShowSuccess($"Composed '{_composeSourceName}' as '{req.SlotName}'.");
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_composeError = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Composition leaf: rename + delete ----
|
||||
private async Task RenameComposition(TemplateComposition composition)
|
||||
{
|
||||
var newName = await Dialog.PromptAsync(
|
||||
"Rename slot",
|
||||
$"New name for slot '{composition.InstanceName}':",
|
||||
initialValue: composition.InstanceName,
|
||||
placeholder: "Slot name");
|
||||
if (string.IsNullOrWhiteSpace(newName) || newName.Trim() == composition.InstanceName) return;
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateService.RenameCompositionAsync(composition.Id, newName.Trim(), user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Slot renamed to '{newName.Trim()}'.");
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteComposition(TemplateComposition composition)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete composition",
|
||||
$"Delete slot '{composition.InstanceName}'? This removes the derived template and any overrides on it.",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await TemplateService.DeleteCompositionAsync(composition.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Composition '{composition.InstanceName}' removed.");
|
||||
await LoadTemplatesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError(result.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
@page "/design/transport/export"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
|
||||
@*
|
||||
TransportExport wizard (Component #24, Task T21).
|
||||
|
||||
A 4-step linear wizard:
|
||||
Step 1 — Select : templates (tree, checkbox-mode) + flat artifact lists.
|
||||
Step 2 — Review : resolved closure split into seed vs auto-included.
|
||||
Step 3 — Encrypt : passphrase + confirm, or explicit unencrypted opt-out.
|
||||
Step 4 — Download : streams the bundle bytes to the browser via JS interop.
|
||||
|
||||
The page is Design-role gated; deeper interactions (audit row, secrets
|
||||
warning) come from BundleExporter itself.
|
||||
*@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h4 class="mb-3">Export Bundle</h4>
|
||||
|
||||
@* Step indicator — Bootstrap progress with discrete numbered pills. *@
|
||||
<nav aria-label="Export wizard steps" class="mb-4">
|
||||
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
|
||||
<li class="@StepClass(ExportWizardStep.Select)">
|
||||
<span class="badge rounded-pill me-1">1</span> Select
|
||||
</li>
|
||||
<li class="@StepClass(ExportWizardStep.Review)">
|
||||
<span class="badge rounded-pill me-1">2</span> Review
|
||||
</li>
|
||||
<li class="@StepClass(ExportWizardStep.Encrypt)">
|
||||
<span class="badge rounded-pill me-1">3</span> Encrypt
|
||||
</li>
|
||||
<li class="@StepClass(ExportWizardStep.Download)">
|
||||
<span class="badge rounded-pill me-1">4</span> Download
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@switch (_step)
|
||||
{
|
||||
case ExportWizardStep.Select:
|
||||
@RenderStepSelect();
|
||||
break;
|
||||
case ExportWizardStep.Review:
|
||||
@RenderStepReview();
|
||||
break;
|
||||
case ExportWizardStep.Encrypt:
|
||||
@RenderStepEncrypt();
|
||||
break;
|
||||
case ExportWizardStep.Download:
|
||||
@RenderStepDownload();
|
||||
break;
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string StepClass(ExportWizardStep s) =>
|
||||
s == _step ? "fw-semibold text-primary"
|
||||
: (int)s < (int)_step ? "text-success"
|
||||
: "text-muted";
|
||||
|
||||
// ============================================================
|
||||
// Step 1 — Select
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepSelect() => __builder =>
|
||||
{
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<label for="export-filter" class="form-label">Search</label>
|
||||
<input id="export-filter" type="search" class="form-control"
|
||||
placeholder="Filter all artifacts…"
|
||||
@bind="_filter" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-templates">
|
||||
<legend class="h6">Templates</legend>
|
||||
@if (_templates.Count == 0)
|
||||
{
|
||||
<div class="text-muted small fst-italic">No templates.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height: 320px; overflow-y: auto; padding: 4px; border: 1px solid var(--bs-border-color); border-radius: 4px;">
|
||||
<TemplateFolderTree Folders="_folders"
|
||||
Templates="_templates"
|
||||
SelectionMode="TreeViewSelectionMode.Checkbox"
|
||||
SelectedKeys="_selectedTemplateKeys"
|
||||
SelectedKeysChanged="OnTemplateSelectionChanged"
|
||||
Filter="@_filter" />
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-shared-scripts">
|
||||
<legend class="h6">Shared Scripts</legend>
|
||||
@RenderCheckboxList(_sharedScripts, s => s.Id, s => s.Name, _selectedSharedScripts)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-external-systems">
|
||||
<legend class="h6">External Systems</legend>
|
||||
@RenderCheckboxList(_externalSystems, e => e.Id, e => e.Name, _selectedExternalSystems)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-db-connections">
|
||||
<legend class="h6">Database Connections</legend>
|
||||
@RenderCheckboxList(_dbConnections, d => d.Id, d => d.Name, _selectedDbConnections)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-notification-lists">
|
||||
<legend class="h6">Notification Lists</legend>
|
||||
@RenderCheckboxList(_notificationLists, n => n.Id, n => n.Name, _selectedNotificationLists)
|
||||
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="smtp-hint">
|
||||
Selecting a notification list does <strong>not</strong> automatically include its
|
||||
SMTP configuration. SMTP configurations are environment-specific and must be
|
||||
selected separately if you want them in the bundle.
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-smtp-configs">
|
||||
<legend class="h6">SMTP Configurations</legend>
|
||||
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-api-keys">
|
||||
<legend class="h6">API Keys</legend>
|
||||
@RenderCheckboxList(_apiKeys, k => k.Id, k => k.Name, _selectedApiKeys)
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-api-methods">
|
||||
<legend class="h6">API Methods</legend>
|
||||
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
|
||||
</fieldset>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||
<button class="btn btn-primary"
|
||||
disabled="@(!HasAnySelection || _resolving)"
|
||||
@onclick="GoToReviewAsync">
|
||||
@(_resolving ? "Resolving…" : "Next")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
private void OnTemplateSelectionChanged(HashSet<object> keys)
|
||||
{
|
||||
// TemplateFolderTree hands back a fresh HashSet each time; mirror it
|
||||
// into our owned set so subsequent renders see the same instance the
|
||||
// tree is binding against.
|
||||
_selectedTemplateKeys.Clear();
|
||||
foreach (var k in keys)
|
||||
{
|
||||
_selectedTemplateKeys.Add(k);
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment RenderCheckboxList<T>(
|
||||
IReadOnlyList<T> items,
|
||||
Func<T, int> idOf,
|
||||
Func<T, string> nameOf,
|
||||
HashSet<int> selected) => __builder =>
|
||||
{
|
||||
var visible = items.Where(x => MatchesFilter(nameOf(x))).ToList();
|
||||
if (visible.Count == 0)
|
||||
{
|
||||
<div class="text-muted small fst-italic">No matches.</div>
|
||||
return;
|
||||
}
|
||||
|
||||
<div class="d-flex flex-column gap-1">
|
||||
@foreach (var item in visible)
|
||||
{
|
||||
var id = idOf(item);
|
||||
var inputId = $"chk-{typeof(T).Name}-{id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="@inputId"
|
||||
checked="@selected.Contains(id)"
|
||||
@onchange="e => Toggle(selected, id, ((bool?)e.Value) == true)" />
|
||||
<label class="form-check-label" for="@inputId">@nameOf(item)</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 2 — Review
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepReview() => __builder =>
|
||||
{
|
||||
if (_resolved is null)
|
||||
{
|
||||
<div class="alert alert-warning">Nothing resolved yet — please go back to step 1.</div>
|
||||
return;
|
||||
}
|
||||
|
||||
var seedTemplateIds = new HashSet<int>(SelectedTemplateIds());
|
||||
var seedSharedScripts = new HashSet<int>(_selectedSharedScripts);
|
||||
var seedExternalSystems = new HashSet<int>(_selectedExternalSystems);
|
||||
|
||||
var autoTemplates = AutoIncluded(_resolved.Templates, seedTemplateIds, t => t.Id);
|
||||
var autoShared = AutoIncluded(_resolved.SharedScripts, seedSharedScripts, s => s.Id);
|
||||
var autoExternals = AutoIncluded(_resolved.ExternalSystems, seedExternalSystems, e => e.Id);
|
||||
|
||||
<div>
|
||||
<p class="text-body-secondary">
|
||||
The resolver walked your selection's dependency graph and produced the closure
|
||||
below. Items under <em>Auto-included</em> were pulled in because the items you
|
||||
ticked reference them; unticking
|
||||
<em>Include all dependencies</em> exports the seed alone.
|
||||
</p>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="include-deps"
|
||||
checked="@_includeDependencies"
|
||||
@onchange="async e => { _includeDependencies = ((bool?)e.Value) == true; await ReresolveAsync(); }" />
|
||||
<label class="form-check-label" for="include-deps">Include all dependencies</label>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6" data-testid="seed-group">
|
||||
<h6>Selected by you</h6>
|
||||
<ul class="small list-unstyled mb-0">
|
||||
@foreach (var t in _resolved.Templates.Where(t => seedTemplateIds.Contains(t.Id)).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>Template: @t.Name</li>
|
||||
}
|
||||
@foreach (var s in _resolved.SharedScripts.Where(s => seedSharedScripts.Contains(s.Id)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>SharedScript: @s.Name</li>
|
||||
}
|
||||
@foreach (var e in _resolved.ExternalSystems.Where(e => seedExternalSystems.Contains(e.Id)).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ExternalSystem: @e.Name</li>
|
||||
}
|
||||
@foreach (var d in _resolved.DatabaseConnections.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>DatabaseConnection: @d.Name</li>
|
||||
}
|
||||
@foreach (var n in _resolved.NotificationLists.OrderBy(n => n.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>NotificationList: @n.Name</li>
|
||||
}
|
||||
@foreach (var s in _resolved.SmtpConfigs.OrderBy(s => s.Host, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>SmtpConfig: @s.Host</li>
|
||||
}
|
||||
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ApiKey: @k.Name</li>
|
||||
}
|
||||
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ApiMethod: @m.Name</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6" data-testid="auto-group">
|
||||
<h6>Auto-included (dependencies)</h6>
|
||||
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count + _resolved.TemplateFolders.Count == 0)
|
||||
{
|
||||
<div class="small text-muted fst-italic">No additional dependencies.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="small list-unstyled mb-0">
|
||||
@foreach (var f in _resolved.TemplateFolders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>TemplateFolder: @f.Name</li>
|
||||
}
|
||||
@foreach (var t in autoTemplates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>Template: @t.Name</li>
|
||||
}
|
||||
@foreach (var s in autoShared.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>SharedScript: @s.Name</li>
|
||||
}
|
||||
@foreach (var e in autoExternals.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<li>ExternalSystem: @e.Name</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToSelect">Back</button>
|
||||
<button class="btn btn-primary" @onclick="GoToEncrypt">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 3 — Encrypt
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepEncrypt() => __builder =>
|
||||
{
|
||||
var strength = PassphraseStrength(_passphrase);
|
||||
var strengthLabel = strength switch
|
||||
{
|
||||
0 => "Too short",
|
||||
1 => "Weak",
|
||||
2 => "Fair",
|
||||
3 => "Good",
|
||||
_ => "Strong",
|
||||
};
|
||||
var strengthColor = strength switch
|
||||
{
|
||||
<= 1 => "bg-danger",
|
||||
2 => "bg-warning",
|
||||
3 => "bg-info",
|
||||
_ => "bg-success",
|
||||
};
|
||||
|
||||
<div>
|
||||
@if (_secretCount > 0)
|
||||
{
|
||||
<div class="alert alert-warning" role="alert" data-testid="secrets-warning">
|
||||
<strong>@_secretCount</strong> secret @(_secretCount == 1 ? "field" : "fields")
|
||||
will be encrypted (external-system credentials, SMTP credentials, and database
|
||||
connection strings).
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info small" role="alert" data-testid="secrets-warning">
|
||||
The selected closure contains no secret fields, but the bundle's content
|
||||
payload will still be encrypted in full when a passphrase is supplied.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="passphrase" class="form-label">Passphrase</label>
|
||||
<input id="passphrase" type="password" class="form-control"
|
||||
autocomplete="new-password"
|
||||
@bind="_passphrase" @bind:event="oninput" />
|
||||
<div class="progress mt-1" style="height: 4px;">
|
||||
<div class="progress-bar @strengthColor"
|
||||
role="progressbar"
|
||||
style="width: @(strength * 25)%;"
|
||||
aria-valuenow="@(strength * 25)" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
<div class="form-text">Strength: @strengthLabel · minimum 8 characters.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="passphrase-confirm" class="form-label">Confirm passphrase</label>
|
||||
<input id="passphrase-confirm" type="password" class="form-control"
|
||||
autocomplete="new-password"
|
||||
@bind="_passphraseConfirm" @bind:event="oninput" />
|
||||
@if (!string.IsNullOrEmpty(_passphraseConfirm) && _passphrase != _passphraseConfirm)
|
||||
{
|
||||
<div class="form-text text-danger">Passphrases do not match.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="small">
|
||||
<a href="javascript:void(0)" class="link-danger" @onclick="OpenUnencryptedConfirm">
|
||||
Export without encryption…
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@if (_showUnencryptedConfirm)
|
||||
{
|
||||
<div class="alert alert-danger" data-testid="unencrypted-confirm">
|
||||
<strong>Unencrypted export</strong> — the bundle will contain all secret fields
|
||||
in plaintext. Anyone with the file can read external-system credentials, SMTP
|
||||
passwords, and database connection strings. The audit log will record this as
|
||||
<code>UnencryptedBundleExport</code>.
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmUnencryptedExport">
|
||||
Yes, export without encryption
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelUnencryptedConfirm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
|
||||
<button class="btn btn-primary"
|
||||
disabled="@(!PassphraseValid)"
|
||||
@onclick="StartEncryptedExportAsync">
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 4 — Download
|
||||
// ============================================================
|
||||
private RenderFragment RenderStepDownload() => __builder =>
|
||||
{
|
||||
<div>
|
||||
@if (_downloadInProgress)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
<p class="text-body-secondary">Building bundle…</p>
|
||||
}
|
||||
else if (_downloadError != null)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>Export failed:</strong> @_downloadError
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-success" data-testid="download-summary">
|
||||
<strong>Bundle ready.</strong> Your browser is downloading the file.
|
||||
</div>
|
||||
|
||||
<dl class="row small">
|
||||
<dt class="col-sm-3">Filename</dt>
|
||||
<dd class="col-sm-9"><code>@_downloadFilename</code></dd>
|
||||
|
||||
<dt class="col-sm-3">Size</dt>
|
||||
<dd class="col-sm-9">@FormatBytes(_downloadSize)</dd>
|
||||
|
||||
<dt class="col-sm-3">SHA-256</dt>
|
||||
<dd class="col-sm-9"><code>@_downloadSha256</code></dd>
|
||||
|
||||
<dt class="col-sm-3">Encryption</dt>
|
||||
<dd class="col-sm-9">
|
||||
@if (_exportUnencrypted)
|
||||
{
|
||||
<span class="text-danger">Unencrypted (audited as <code>UnencryptedBundleExport</code>)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-success">AES-256-GCM with PBKDF2-SHA256</span>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<button class="btn btn-primary" @onclick="Done">Done</button>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
if (bytes < 1024) return $"{bytes} B";
|
||||
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:0.0} KB";
|
||||
return $"{bytes / (1024.0 * 1024.0):0.00} MB";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.JSInterop;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Export;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the TransportExport wizard (Transport feature, Task T21).
|
||||
///
|
||||
/// Four-step state machine:
|
||||
/// <list type="number">
|
||||
/// <item><see cref="ExportWizardStep.Select"/> — pick templates + flat artifact lists.</item>
|
||||
/// <item><see cref="ExportWizardStep.Review"/> — show resolved closure (seed + auto-included).</item>
|
||||
/// <item><see cref="ExportWizardStep.Encrypt"/> — passphrase + secret-count warning, or explicit unencrypted opt-out.</item>
|
||||
/// <item><see cref="ExportWizardStep.Download"/> — call <see cref="IBundleExporter"/>, render the file via JS interop.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The wizard transitions are linear; "Back" returns to the previous step
|
||||
/// without clearing selection state. "Done" on Step 4 resets to Step 1 fresh.
|
||||
///
|
||||
/// <c>SourceEnvironment</c> is sourced from <see cref="TransportOptions.SourceEnvironment"/>
|
||||
/// (bound from <c>ScadaBridge:Transport:SourceEnvironment</c>) so a multi-cluster
|
||||
/// deployment can label its bundles distinctly. Defaults to <c>"scadabridge"</c>.
|
||||
/// </summary>
|
||||
public partial class TransportExport : ComponentBase
|
||||
{
|
||||
public enum ExportWizardStep
|
||||
{
|
||||
Select = 1,
|
||||
Review = 2,
|
||||
Encrypt = 3,
|
||||
Download = 4,
|
||||
}
|
||||
|
||||
// ---- Injected services ----
|
||||
[Inject] private IBundleExporter BundleExporter { get; set; } = default!;
|
||||
[Inject] private ITemplateEngineRepository TemplateRepo { get; set; } = default!;
|
||||
[Inject] private IExternalSystemRepository ExternalRepo { get; set; } = default!;
|
||||
[Inject] private INotificationRepository NotificationRepo { get; set; } = default!;
|
||||
[Inject] private IInboundApiRepository InboundApiRepo { get; set; } = default!;
|
||||
[Inject] private DependencyResolver DepResolver { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
||||
[Inject] private IOptions<TransportOptions> TransportOptions { get; set; } = default!;
|
||||
|
||||
// ---- Wizard state ----
|
||||
private ExportWizardStep _step = ExportWizardStep.Select;
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
// ---- Step 1: source data ----
|
||||
private List<Template> _templates = new();
|
||||
private List<TemplateFolder> _folders = new();
|
||||
private List<SharedScript> _sharedScripts = new();
|
||||
private List<ExternalSystemDefinition> _externalSystems = new();
|
||||
private List<DatabaseConnectionDefinition> _dbConnections = new();
|
||||
private List<NotificationList> _notificationLists = new();
|
||||
private List<SmtpConfiguration> _smtpConfigs = new();
|
||||
private List<ApiKey> _apiKeys = new();
|
||||
private List<ApiMethod> _apiMethods = new();
|
||||
|
||||
// ---- Step 1: selection state ----
|
||||
// TemplateFolderTree uses string keys ("t:{id}", "f:{id}") via TemplateTreeNode.Key.
|
||||
// Templates are selected via the tree; the other artifacts use flat checkbox lists
|
||||
// backed by integer-id HashSets so wiring is uniform across categories.
|
||||
private readonly HashSet<object> _selectedTemplateKeys = new();
|
||||
private readonly HashSet<int> _selectedSharedScripts = new();
|
||||
private readonly HashSet<int> _selectedExternalSystems = new();
|
||||
private readonly HashSet<int> _selectedDbConnections = new();
|
||||
private readonly HashSet<int> _selectedNotificationLists = new();
|
||||
private readonly HashSet<int> _selectedSmtpConfigs = new();
|
||||
private readonly HashSet<int> _selectedApiKeys = new();
|
||||
private readonly HashSet<int> _selectedApiMethods = new();
|
||||
private string _filter = string.Empty;
|
||||
private bool _includeDependencies = true;
|
||||
|
||||
// ---- Step 2: dependency-resolved closure ----
|
||||
private ResolvedExport? _resolved;
|
||||
private bool _resolving;
|
||||
|
||||
// ---- Step 3: encryption ----
|
||||
private string _passphrase = string.Empty;
|
||||
private string _passphraseConfirm = string.Empty;
|
||||
private bool _showUnencryptedConfirm;
|
||||
private bool _exportUnencrypted;
|
||||
private int _secretCount;
|
||||
|
||||
// ---- Step 4: download result ----
|
||||
private string? _downloadFilename;
|
||||
private long _downloadSize;
|
||||
private string? _downloadSha256;
|
||||
private bool _downloadInProgress;
|
||||
private string? _downloadError;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAllAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAllAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_templates = (await TemplateRepo.GetAllTemplatesAsync()).ToList();
|
||||
_folders = (await TemplateRepo.GetAllFoldersAsync()).ToList();
|
||||
_sharedScripts = (await TemplateRepo.GetAllSharedScriptsAsync()).ToList();
|
||||
_externalSystems = (await ExternalRepo.GetAllExternalSystemsAsync()).ToList();
|
||||
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
|
||||
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
|
||||
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
|
||||
_apiKeys = (await InboundApiRepo.GetAllApiKeysAsync()).ToList();
|
||||
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load export source data: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selection helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Project the tree's checkbox-keys back to template ids. Template keys are
|
||||
/// the strings produced by <c>TemplateTreeNode.Key</c> — folder ids are
|
||||
/// excluded (folders aren't directly exportable; their templates are).
|
||||
/// </summary>
|
||||
private IReadOnlyList<int> SelectedTemplateIds()
|
||||
{
|
||||
var ids = new List<int>();
|
||||
foreach (var key in _selectedTemplateKeys)
|
||||
{
|
||||
if (key is string s && s.StartsWith("t:", StringComparison.Ordinal)
|
||||
&& int.TryParse(s.AsSpan(2), out var id))
|
||||
{
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the user has ticked at least one item in any category. Drives
|
||||
/// the "Next" button on Step 1.
|
||||
/// </summary>
|
||||
internal bool HasAnySelection =>
|
||||
SelectedTemplateIds().Count > 0
|
||||
|| _selectedSharedScripts.Count > 0
|
||||
|| _selectedExternalSystems.Count > 0
|
||||
|| _selectedDbConnections.Count > 0
|
||||
|| _selectedNotificationLists.Count > 0
|
||||
|| _selectedSmtpConfigs.Count > 0
|
||||
|| _selectedApiKeys.Count > 0
|
||||
|| _selectedApiMethods.Count > 0;
|
||||
|
||||
private bool PassphraseValid =>
|
||||
!string.IsNullOrEmpty(_passphrase)
|
||||
&& _passphrase.Length >= 8
|
||||
&& _passphrase == _passphraseConfirm;
|
||||
|
||||
/// <summary>
|
||||
/// Coarse strength score 0-4 based on length + character-class diversity. Used
|
||||
/// to colour an inline strength meter; never used to gate the export — the
|
||||
/// importer enforces its own strength + lockout policies.
|
||||
/// </summary>
|
||||
/// <param name="s">The passphrase string to score.</param>
|
||||
internal static int PassphraseStrength(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return 0;
|
||||
var score = 0;
|
||||
if (s.Length >= 8) score++;
|
||||
if (s.Length >= 16) score++;
|
||||
if (s.Any(char.IsUpper) && s.Any(char.IsLower)) score++;
|
||||
if (s.Any(char.IsDigit) && s.Any(ch => !char.IsLetterOrDigit(ch))) score++;
|
||||
return Math.Min(4, score);
|
||||
}
|
||||
|
||||
// ---- Wizard transitions ----
|
||||
|
||||
private ExportSelection BuildSelection()
|
||||
{
|
||||
return new ExportSelection(
|
||||
TemplateIds: SelectedTemplateIds(),
|
||||
SharedScriptIds: _selectedSharedScripts.ToList(),
|
||||
ExternalSystemIds: _selectedExternalSystems.ToList(),
|
||||
DatabaseConnectionIds: _selectedDbConnections.ToList(),
|
||||
NotificationListIds: _selectedNotificationLists.ToList(),
|
||||
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
|
||||
ApiKeyIds: _selectedApiKeys.ToList(),
|
||||
ApiMethodIds: _selectedApiMethods.ToList(),
|
||||
IncludeDependencies: _includeDependencies);
|
||||
}
|
||||
|
||||
private async Task GoToReviewAsync()
|
||||
{
|
||||
if (!HasAnySelection) return;
|
||||
_resolving = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
var selection = BuildSelection();
|
||||
_resolved = await DepResolver.ResolveAsync(selection, CancellationToken.None);
|
||||
_step = ExportWizardStep.Review;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to resolve dependencies: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReresolveAsync()
|
||||
{
|
||||
// Re-run resolution from Step 2 when the user toggles IncludeDependencies.
|
||||
_resolving = true;
|
||||
try
|
||||
{
|
||||
_resolved = await DepResolver.ResolveAsync(BuildSelection(), CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void GoToEncrypt()
|
||||
{
|
||||
// Recompute the secret-field count from the resolved closure so the
|
||||
// warning banner stays honest if the user backed up and re-resolved.
|
||||
if (_resolved is not null)
|
||||
{
|
||||
_secretCount = CountSecrets(_resolved);
|
||||
}
|
||||
_step = ExportWizardStep.Encrypt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Count the secret fields that <see cref="BundleSecretEncryptor"/> will
|
||||
/// envelope-encrypt. Surfaces in the Step 3 warning banner so the user
|
||||
/// knows exactly what an unencrypted export would leak.
|
||||
/// </summary>
|
||||
/// <param name="resolved">The resolved export closure whose secret fields are counted.</param>
|
||||
internal static int CountSecrets(ResolvedExport resolved)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var es in resolved.ExternalSystems)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(es.AuthConfiguration)) count++;
|
||||
}
|
||||
foreach (var smtp in resolved.SmtpConfigs)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(smtp.Credentials)) count++;
|
||||
}
|
||||
foreach (var db in resolved.DatabaseConnections)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(db.ConnectionString)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void BackToSelect() => _step = ExportWizardStep.Select;
|
||||
private void BackToReview() => _step = ExportWizardStep.Review;
|
||||
|
||||
private void OpenUnencryptedConfirm()
|
||||
{
|
||||
_showUnencryptedConfirm = true;
|
||||
}
|
||||
|
||||
private async Task ConfirmUnencryptedExport()
|
||||
{
|
||||
_showUnencryptedConfirm = false;
|
||||
_exportUnencrypted = true;
|
||||
await StartExportAsync(passphrase: null);
|
||||
}
|
||||
|
||||
private void CancelUnencryptedConfirm()
|
||||
{
|
||||
_showUnencryptedConfirm = false;
|
||||
}
|
||||
|
||||
private async Task StartEncryptedExportAsync()
|
||||
{
|
||||
if (!PassphraseValid) return;
|
||||
_exportUnencrypted = false;
|
||||
await StartExportAsync(_passphrase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final export step: invokes <see cref="IBundleExporter.ExportAsync"/>,
|
||||
/// captures the bundle bytes, computes a display-side SHA-256 (matching
|
||||
/// the manifest's content hash naming), and pushes the file to the browser
|
||||
/// via JS interop. Errors surface inline; the page never throws to the user.
|
||||
/// </summary>
|
||||
private async Task StartExportAsync(string? passphrase)
|
||||
{
|
||||
_step = ExportWizardStep.Download;
|
||||
_downloadInProgress = true;
|
||||
_downloadError = null;
|
||||
try
|
||||
{
|
||||
var user = await Auth.GetCurrentUsernameAsync();
|
||||
var sourceEnv = TransportOptions.Value.SourceEnvironment;
|
||||
if (string.IsNullOrWhiteSpace(sourceEnv))
|
||||
{
|
||||
sourceEnv = "scadabridge";
|
||||
}
|
||||
|
||||
var selection = BuildSelection();
|
||||
await using var stream = await BundleExporter.ExportAsync(
|
||||
selection, user, sourceEnv, passphrase, CancellationToken.None);
|
||||
|
||||
byte[] bytes;
|
||||
if (stream is MemoryStream ms)
|
||||
{
|
||||
bytes = ms.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
using var copy = new MemoryStream();
|
||||
await stream.CopyToAsync(copy);
|
||||
bytes = copy.ToArray();
|
||||
}
|
||||
|
||||
_downloadSize = bytes.LongLength;
|
||||
_downloadSha256 = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
_downloadFilename = BuildFilename(sourceEnv);
|
||||
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
await JS.InvokeVoidAsync("scadabridgeTransport.downloadBundle", _downloadFilename, base64);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_downloadError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filename pattern <c>scadabundle-{sourceEnv}-{yyyy-MM-dd-HHmmss}.scadabundle</c>.
|
||||
/// Source environment characters are sanitised to a filename-safe alphabet so
|
||||
/// odd chars in <c>TransportOptions.SourceEnvironment</c> don't produce
|
||||
/// browser-rejected filenames.
|
||||
/// </summary>
|
||||
/// <param name="sourceEnvironment">The environment label to embed in the filename (sanitised to filename-safe characters).</param>
|
||||
/// <param name="nowUtc">Timestamp to use for the datetime segment; defaults to <see cref="DateTimeOffset.UtcNow"/> when null.</param>
|
||||
internal static string BuildFilename(string sourceEnvironment, DateTimeOffset? nowUtc = null)
|
||||
{
|
||||
var safe = SanitizeForFilename(sourceEnvironment);
|
||||
var ts = (nowUtc ?? DateTimeOffset.UtcNow).ToString("yyyy-MM-dd-HHmmss");
|
||||
return $"scadabundle-{safe}-{ts}.scadabundle";
|
||||
}
|
||||
|
||||
private static string SanitizeForFilename(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return "scadabridge";
|
||||
var chars = input.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private void Done()
|
||||
{
|
||||
// Reset every wizard piece so the operator can immediately start a fresh
|
||||
// export without page-refresh-induced data reload.
|
||||
_step = ExportWizardStep.Select;
|
||||
_selectedTemplateKeys.Clear();
|
||||
_selectedSharedScripts.Clear();
|
||||
_selectedExternalSystems.Clear();
|
||||
_selectedDbConnections.Clear();
|
||||
_selectedNotificationLists.Clear();
|
||||
_selectedSmtpConfigs.Clear();
|
||||
_selectedApiKeys.Clear();
|
||||
_selectedApiMethods.Clear();
|
||||
_filter = string.Empty;
|
||||
_includeDependencies = true;
|
||||
_resolved = null;
|
||||
_passphrase = string.Empty;
|
||||
_passphraseConfirm = string.Empty;
|
||||
_exportUnencrypted = false;
|
||||
_showUnencryptedConfirm = false;
|
||||
_downloadFilename = null;
|
||||
_downloadSize = 0;
|
||||
_downloadSha256 = null;
|
||||
_downloadError = null;
|
||||
}
|
||||
|
||||
// ---- Flat-list filter helpers (search box reuses TemplateFolderTree.Filter for the tree) ----
|
||||
private bool MatchesFilter(string name) =>
|
||||
string.IsNullOrWhiteSpace(_filter)
|
||||
|| name.Contains(_filter, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static void Toggle(HashSet<int> set, int id, bool value)
|
||||
{
|
||||
if (value) set.Add(id);
|
||||
else set.Remove(id);
|
||||
}
|
||||
|
||||
// ---- Step 2 grouping helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Items that are in <paramref name="all"/> but NOT in <paramref name="seed"/> —
|
||||
/// the auto-included dependencies the resolver pulled in for the user.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The element type of the artifact list.</typeparam>
|
||||
/// <param name="all">The full resolved list including both seed and auto-included items.</param>
|
||||
/// <param name="seed">The set of explicitly selected item ids.</param>
|
||||
/// <param name="idOf">Function that extracts the integer id from an item.</param>
|
||||
internal static IReadOnlyList<T> AutoIncluded<T>(IReadOnlyList<T> all, IReadOnlyCollection<int> seed, Func<T, int> idOf)
|
||||
{
|
||||
return all.Where(x => !seed.Contains(idOf(x))).ToList();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
using System.Security.Cryptography;
|
||||
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.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!;
|
||||
|
||||
// ---- 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 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()
|
||||
{
|
||||
_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>
|
||||
/// <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();
|
||||
_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;
|
||||
DeleteBundleTempFile();
|
||||
_preview = null;
|
||||
_resolutions = null;
|
||||
_passphrase = string.Empty;
|
||||
_confirmEnvironmentText = string.Empty;
|
||||
_result = null;
|
||||
_validationErrors = null;
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user