@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). 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. *@
@if (_loading) { } else if (_errorMessage != null) {
@_errorMessage
} else {

Export Bundle

@* Step indicator — Bootstrap progress with discrete numbered pills. *@ @switch (_step) { case ExportWizardStep.Select: @RenderStepSelect(); break; case ExportWizardStep.Review: @RenderStepReview(); break; case ExportWizardStep.Encrypt: @RenderStepEncrypt(); break; case ExportWizardStep.Download: @RenderStepDownload(); break; } }
@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 => {
Templates @if (_templates.Count == 0) {
No templates.
} else {
}
Shared Scripts @RenderCheckboxList(_sharedScripts, s => s.Id, s => s.Name, _selectedSharedScripts)
External Systems @RenderCheckboxList(_externalSystems, e => e.Id, e => e.Name, _selectedExternalSystems)
Database Connections @RenderCheckboxList(_dbConnections, d => d.Id, d => d.Name, _selectedDbConnections)
Notification Lists @RenderCheckboxList(_notificationLists, n => n.Id, n => n.Name, _selectedNotificationLists)
SMTP Configurations @RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
@* SMS provider configs, mirroring the SMTP section above. Labelled by AccountSid (the bundle key); the secret AuthToken is never rendered. *@
SMS Configurations @RenderCheckboxList(_smsConfigs, s => s.Id, s => s.AccountSid, _selectedSmsConfigs)
API Methods @RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
Sites & Instances @RenderSitesList()
}; private void OnTemplateSelectionChanged(HashSet 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( IReadOnlyList items, Func idOf, Func nameOf, HashSet selected) => __builder => { var visible = items.Where(x => MatchesFilter(nameOf(x))).ToList(); if (visible.Count == 0) {
No matches.
return; }
@foreach (var item in visible) { var id = idOf(item); var inputId = $"chk-{typeof(T).Name}-{id}";
}
}; // Sites & Instances picker (M8 E1): a flat site checkbox list, each row // expandable to reveal its instances. The search box matches a site by its // Name or SiteIdentifier, and an instance by its UniqueName; a site stays // visible when any of its instances match so the operator can drill in. private RenderFragment RenderSitesList() => __builder => { var visibleSites = _sites .Where(site => MatchesFilter(site.Name) || MatchesFilter(site.SiteIdentifier) || InstancesFor(site.Id).Any(i => MatchesFilter(i.UniqueName))) .ToList(); if (visibleSites.Count == 0) {
@(_sites.Count == 0 ? "No sites." : "No matches.")
return; }
@foreach (var site in visibleSites) { var siteInputId = $"chk-site-{site.Id}"; var expanded = _expandedSites.Contains(site.Id); var instances = InstancesFor(site.Id);
@if (instances.Count > 0) { } else { }
@if (expanded && instances.Count > 0) {
@foreach (var instance in instances.Where(i => MatchesFilter(i.UniqueName) || MatchesFilter(site.Name) || MatchesFilter(site.SiteIdentifier))) { var instInputId = $"chk-instance-{instance.Id}"; var siteSelected = _selectedSites.Contains(site.Id);
}
}
}
}; // ============================================================ // Step 2 — Review // ============================================================ private RenderFragment RenderStepReview() => __builder => { if (_resolved is null) {
Nothing resolved yet — please go back to step 1.
return; } var seedTemplateIds = new HashSet(SelectedTemplateIds()); var seedSharedScripts = new HashSet(_selectedSharedScripts); var seedExternalSystems = new HashSet(_selectedExternalSystems); // M8 (E1): sites/instances the operator ticked directly are "seed"; everything // else the resolver pulled into Sites/Instances (e.g. an instance's owning site, // or a site's instances) is auto-included. var seedSiteIds = new HashSet(_selectedSites); var seedInstanceIds = new HashSet(_selectedInstances); 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); var autoSites = AutoIncluded(_resolved.Sites, seedSiteIds, s => s.Id); var autoInstances = AutoIncluded(_resolved.Instances, seedInstanceIds, i => i.Id);

The resolver walked your selection's dependency graph and produced the closure below. Items under Auto-included were pulled in because the items you ticked reference them; unticking Include all dependencies exports the seed alone.

Selected by you
    @foreach (var t in _resolved.Templates.Where(t => seedTemplateIds.Contains(t.Id)).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)) {
  • Template: @t.Name
  • } @foreach (var s in _resolved.SharedScripts.Where(s => seedSharedScripts.Contains(s.Id)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)) {
  • SharedScript: @s.Name
  • } @foreach (var e in _resolved.ExternalSystems.Where(e => seedExternalSystems.Contains(e.Id)).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)) {
  • ExternalSystem: @e.Name
  • } @foreach (var d in _resolved.DatabaseConnections.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)) {
  • DatabaseConnection: @d.Name
  • } @foreach (var n in _resolved.NotificationLists.OrderBy(n => n.Name, StringComparer.OrdinalIgnoreCase)) {
  • NotificationList: @n.Name
  • } @foreach (var s in _resolved.SmtpConfigs.OrderBy(s => s.Host, StringComparer.OrdinalIgnoreCase)) {
  • SmtpConfig: @s.Host
  • } @* SMS configs in the closure, mirroring SmtpConfig above; AccountSid only, never AuthToken. *@ @foreach (var s in _resolved.SmsConfigs.OrderBy(s => s.AccountSid, StringComparer.OrdinalIgnoreCase)) {
  • SmsConfig: @s.AccountSid
  • } @* Inbound API keys are not transported — methods only. *@ @foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase)) {
  • ApiMethod: @m.Name
  • } @foreach (var s in _resolved.Sites.Where(s => seedSiteIds.Contains(s.Id)).OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase)) {
  • Site: @s.Name (@s.SiteIdentifier)
  • } @foreach (var i in _resolved.Instances.Where(i => seedInstanceIds.Contains(i.Id)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)) {
  • Instance: @i.UniqueName
  • }
Auto-included (dependencies)
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count + autoSites.Count + autoInstances.Count + _resolved.DataConnections.Count + _resolved.TemplateFolders.Count == 0) {
No additional dependencies.
} else {
    @foreach (var f in _resolved.TemplateFolders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase)) {
  • TemplateFolder: @f.Name
  • } @foreach (var t in autoTemplates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)) {
  • Template: @t.Name
  • } @foreach (var s in autoShared.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)) {
  • SharedScript: @s.Name
  • } @foreach (var e in autoExternals.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)) {
  • ExternalSystem: @e.Name
  • } @foreach (var s in autoSites.OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase)) {
  • Site: @s.Name (@s.SiteIdentifier)
  • } @foreach (var i in autoInstances.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)) {
  • Instance: @i.UniqueName
  • } @foreach (var c in _resolved.DataConnections.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) {
  • DataConnection: @c.Name
  • }
}
}; // ============================================================ // 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", };
@if (_secretCount > 0) { } else { }
Strength: @strengthLabel · minimum 8 characters.
@if (!string.IsNullOrEmpty(_passphraseConfirm) && _passphrase != _passphraseConfirm) {
Passphrases do not match.
}

Export without encryption…

@if (_showUnencryptedConfirm) {
Unencrypted export — the bundle will contain all secret fields in plaintext. Anyone with the file can read external-system credentials, SMTP passwords, SMS auth tokens, and database connection strings. The audit log will record this as UnencryptedBundleExport.
}
}; // ============================================================ // Step 4 — Download // ============================================================ private RenderFragment RenderStepDownload() => __builder => {
@if (_downloadInProgress) {

Building bundle…

} else if (_downloadError != null) {
Export failed: @_downloadError
} else {
Bundle ready. Your browser is downloading the file.
Filename
@_downloadFilename
Size
@FormatBytes(_downloadSize)
SHA-256
@_downloadSha256
Encryption
@if (_exportUnencrypted) { Unencrypted (audited as UnencryptedBundleExport) } else { AES-256-GCM with PBKDF2-SHA256 }
}
}; 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"; } }