Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor
T
Joseph Doherty 198770f578 fix(deploy): address M2.2 review nits — backup endpoint in diff summary + null-oldConfig test (#10)
- FormatConnection now includes BackupConfigurationJson so a backup-only change
  no longer renders identical Before/After cells (covers all 4 ConnectionsEqual fields)
- add ComputeConnectionsDiff(null, newConfig) first-deploy unit test
2026-06-15 13:41:39 -04:00

1061 lines
40 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/deployment/topology"
@page "/deployment/instances"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject IDeploymentManagerRepository DeploymentManagerRepository
@inject DeploymentService DeploymentService
@inject AreaService AreaService
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime
@inject IDialogService Dialog
@implements IDisposable
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<CreateAreaDialog @bind-IsVisible="_showCreateAreaDialog"
RequireSitePicker="_createAreaRequireSitePicker"
ContextLabel="@_createAreaContextLabel"
SiteId="_createAreaSiteId"
ParentAreaId="_createAreaParentId"
SiteOptions="EnumerateSiteOptions()"
ParentOptions="EnumerateAreaOptionsForCreate()"
ErrorMessage="@_createAreaError"
OnSubmit="SubmitCreateArea" />
<MoveAreaDialog @bind-IsVisible="_showMoveAreaDialog"
AreaId="_moveAreaId"
AreaName="@_moveAreaName"
CurrentParentId="_moveAreaCurrentParentId"
ParentOptions="EnumerateAreaParentOptionsExcluding(_moveAreaId, _moveAreaSiteId)"
ErrorMessage="@_moveAreaError"
OnSubmit="SubmitMoveArea" />
<MoveInstanceDialog @bind-IsVisible="_showMoveInstanceDialog"
InstanceId="_moveInstanceId"
InstanceName="@_moveInstanceName"
CurrentAreaId="_moveInstanceCurrentAreaId"
AreaOptions="EnumerateAreaOptionsForSite(_moveInstanceSiteId)"
ErrorMessage="@_moveInstanceError"
OnSubmit="SubmitMoveInstance" />
@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">Topology</h4>
</div>
<div class="d-flex align-items-center mb-2 gap-2 flex-wrap">
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
placeholder="Search sites, areas, instances..."
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
<button class="btn btn-outline-secondary"
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
<button class="btn btn-outline-secondary" aria-label="Refresh topology" @onclick="LoadDataAsync">Refresh</button>
<button class="btn btn-outline-secondary" aria-label="Expand all areas" @onclick="() => _tree?.ExpandAll()">Expand</button>
<button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
</div>
<div class="form-check form-switch ms-2 mb-0">
<input type="checkbox" class="form-check-input" id="live-updates"
checked="@_liveUpdates" @onchange="OnLiveUpdatesToggled" />
<label class="form-check-label small" for="live-updates">Live updates</label>
</div>
</div>
<div class="alert alert-light py-2 mb-3 small">
@_allAreas.Count area(s) · @_allInstances.Count instance(s) across @_sites.Count site(s).
</div>
<div style="max-height: calc(100vh - 240px); overflow-y: auto; padding: 4px;">
<TreeView @ref="_tree" TItem="TopoNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Key"
StorageKey="topology-tree"
Selectable="true"
SelectedKey="_selectedKey"
SelectedKeyChanged="OnTreeNodeSelected">
<NodeContent Context="node">
@RenderNodeLabel(node)
</NodeContent>
<ContextMenu Context="node">
@RenderNodeContextMenu(node)
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
</EmptyContent>
</TreeView>
</div>
<DiffDialog @ref="_diffDialog" />
}
</div>
@code {
// ---- Data ----
private List<Instance> _allInstances = new();
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
private Dictionary<int, bool> _stalenessMap = new();
private bool _loading = true;
private string? _errorMessage;
private bool _actionInProgress;
private string _searchText = string.Empty;
private ToastNotification _toast = default!;
private DiffDialog _diffDialog = default!;
// ---- Live updates ----
private bool _liveUpdates = true;
private Timer? _liveUpdatesTimer;
private static readonly TimeSpan LiveUpdatesInterval = TimeSpan.FromSeconds(15);
private TreeView<TopoNode> _tree = default!;
private object? _selectedKey;
private const string SelectedKeyStorage = "topology-tree-selected";
// ---- Tree model ----
private enum TopoNodeKind { Site, Area, Instance }
private record TopoNode(
string Key,
TopoNodeKind Kind,
int EntityId,
int SiteId,
string Label,
Site? Site,
Area? Area,
Instance? Instance,
bool IsStale,
bool MatchesSearch,
List<TopoNode> Children);
private List<TopoNode> _treeRoots = new();
// ---- Inline rename ----
private string? _renamingKey;
private string _renameBuffer = string.Empty;
private string? _renameError;
// ---- Lifecycle ----
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
StartLiveUpdatesTimer();
}
private void StartLiveUpdatesTimer()
{
_liveUpdatesTimer?.Dispose();
if (!_liveUpdates) return;
_liveUpdatesTimer = new Timer(_ =>
{
InvokeAsync(async () =>
{
if (!_liveUpdates) return;
await LoadDataAsync();
StateHasChanged();
});
}, null, LiveUpdatesInterval, LiveUpdatesInterval);
}
private void OnLiveUpdatesToggled(ChangeEventArgs e)
{
_liveUpdates = e.Value is bool b && b;
if (_liveUpdates)
{
StartLiveUpdatesTimer();
}
else
{
_liveUpdatesTimer?.Dispose();
_liveUpdatesTimer = null;
}
}
public void Dispose()
{
_liveUpdatesTimer?.Dispose();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var stored = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", SelectedKeyStorage);
if (!string.IsNullOrEmpty(stored))
{
_selectedKey = stored;
StateHasChanged();
}
}
catch { /* no JS interop available (prerender, tests) — ignore */ }
}
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
// Site scoping (CentralUI-002): a scoped Deployment user only sees the
// sites — and therefore the areas/instances — they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
var permittedSiteIds = _sites.Select(s => s.Id).ToHashSet();
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
.Where(i => permittedSiteIds.Contains(i.SiteId))
.ToList();
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_allAreas.Clear();
foreach (var site in _sites)
{
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
_allAreas.AddRange(areas);
}
_stalenessMap.Clear();
foreach (var inst in _allInstances.Where(i => i.State != InstanceState.NotDeployed))
{
try
{
var comparison = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
_stalenessMap[inst.Id] = comparison.IsSuccess && comparison.Value.IsStale;
}
catch
{
_stalenessMap[inst.Id] = false;
}
}
BuildTree();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load topology: {ex.Message}";
}
_loading = false;
}
private void OnSearchChanged()
{
BuildTree();
}
private bool NodeMatchesSearch(string label)
{
if (string.IsNullOrWhiteSpace(_searchText)) return true;
return label.Contains(_searchText, StringComparison.OrdinalIgnoreCase);
}
private void BuildTree()
{
_treeRoots = _sites
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.Select(site =>
{
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
var siteInstances = _allInstances.Where(i => i.SiteId == site.Id).ToList();
var areaChildren = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
var rootInstances = siteInstances
.Where(i => i.AreaId == null)
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.Select(MakeInstanceNode)
.ToList();
var children = areaChildren.Concat(rootInstances).ToList();
return new TopoNode(
Key: $"s:{site.Id}",
Kind: TopoNodeKind.Site,
EntityId: site.Id,
SiteId: site.Id,
Label: site.Name,
Site: site,
Area: null,
Instance: null,
IsStale: false,
MatchesSearch: NodeMatchesSearch(site.Name),
Children: children);
})
.ToList();
}
private List<TopoNode> BuildAreaNodes(List<Area> allAreas, List<Instance> instances, int? parentId)
{
return allAreas
.Where(a => a.ParentAreaId == parentId)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.Select(area =>
{
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
var areaInstances = instances
.Where(i => i.AreaId == area.Id)
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.Select(MakeInstanceNode)
.ToList();
var children = childAreas.Concat(areaInstances).ToList();
return new TopoNode(
Key: $"a:{area.Id}",
Kind: TopoNodeKind.Area,
EntityId: area.Id,
SiteId: area.SiteId,
Label: area.Name,
Site: null,
Area: area,
Instance: null,
IsStale: false,
MatchesSearch: NodeMatchesSearch(area.Name),
Children: children);
})
.ToList();
}
private TopoNode MakeInstanceNode(Instance inst) => new(
Key: $"i:{inst.Id}",
Kind: TopoNodeKind.Instance,
EntityId: inst.Id,
SiteId: inst.SiteId,
Label: inst.UniqueName,
Site: null,
Area: null,
Instance: inst,
IsStale: _stalenessMap.GetValueOrDefault(inst.Id),
MatchesSearch: NodeMatchesSearch(inst.UniqueName),
Children: new List<TopoNode>());
// ---- Rendering ----
private RenderFragment RenderNodeLabel(TopoNode node) => __builder =>
{
var dim = !string.IsNullOrWhiteSpace(_searchText) && !SubtreeContainsMatch(node);
var labelStyle = dim ? "opacity: 0.4;" : null;
switch (node.Kind)
{
case TopoNodeKind.Site:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-building"></i></span>
<span class="tv-label fw-semibold" style="@labelStyle" title="@node.Label">@node.Label</span>
break;
case TopoNodeKind.Area:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-diagram-3"></i></span>
@if (_renamingKey == node.Key)
{
<input class="form-control form-control-sm d-inline-block" style="width: auto; max-width: 220px;"
aria-label="@($"Rename {node.Label}")"
@ref="_renameInput"
@bind="_renameBuffer"
@onkeydown="(e) => OnRenameKeyDown(e, node)"
@onblur="() => CancelRename()" />
@if (!string.IsNullOrEmpty(_renameError))
{
<span class="text-danger small ms-2">@_renameError</span>
}
}
else
{
<span class="tv-label" style="@labelStyle" title="@node.Label"
@ondblclick="() => BeginRename(node)">@node.Label</span>
}
break;
case TopoNodeKind.Instance:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-box"></i></span>
<span class="tv-label" style="@labelStyle" title="@node.Label">@node.Label</span>
<span class="badge @GetStateBadge(node.Instance!.State) ms-1" style="@labelStyle">@node.Instance!.State</span>
@if (node.Instance!.State != InstanceState.NotDeployed)
{
@if (node.IsStale)
{
<span class="badge bg-warning text-dark ms-1" style="@labelStyle" aria-label="State: Stale">
<i class="bi bi-exclamation-triangle me-1"></i>Stale
</span>
}
else
{
<span class="badge bg-light text-dark ms-1" style="@labelStyle" aria-label="State: Current">Current</span>
}
}
break;
}
};
private static bool SubtreeContainsMatch(TopoNode node)
{
if (node.MatchesSearch) return true;
return node.Children.Any(SubtreeContainsMatch);
}
private RenderFragment RenderNodeContextMenu(TopoNode node) => __builder =>
{
switch (node.Kind)
{
case TopoNodeKind.Site:
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForSite(node.EntityId)">Add Area</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.EntityId}")'>
Create Instance here
</button>
break;
case TopoNodeKind.Area:
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForArea(node.SiteId, node.EntityId, node.Label)">Add Sub-area</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.SiteId}&areaId={node.EntityId}")'>
Create Instance here
</button>
<button class="dropdown-item" @onclick="() => OpenMoveAreaDialog(node)">Move to Area…</button>
<button class="dropdown-item" @onclick="() => BeginRename(node)">Rename…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteArea(node)" disabled="@_actionInProgress">Delete</button>
break;
case TopoNodeKind.Instance:
var inst = node.Instance!;
var isStale = node.IsStale;
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
@if (inst.State == InstanceState.Enabled)
{
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
disabled="@_actionInProgress">Disable</button>
}
else if (inst.State == InstanceState.Disabled)
{
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
disabled="@_actionInProgress">Enable</button>
}
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
Configure
</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/debug-view?siteId={node.SiteId}&instanceId={inst.Id}")'
disabled="@(inst.State != InstanceState.Enabled)">
Debug View
</button>
<button class="dropdown-item" @onclick="() => ShowDiff(inst)"
disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
<button class="dropdown-item" @onclick="() => OpenMoveInstanceDialog(node)">Move to Area…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
disabled="@_actionInProgress">Delete</button>
break;
}
};
private static string GetStateBadge(InstanceState state) => state switch
{
InstanceState.Enabled => "bg-success",
InstanceState.Disabled => "bg-secondary",
InstanceState.NotDeployed => "bg-light text-dark",
_ => "bg-secondary"
};
// ---- Selection ----
private async Task OnTreeNodeSelected(object? key)
{
_selectedKey = key;
try
{
await JSRuntime.InvokeVoidAsync("treeviewStorage.save", SelectedKeyStorage,
key?.ToString() ?? string.Empty);
}
catch { /* ignore */ }
}
// ---- Inline rename ----
private ElementReference _renameInput;
private void BeginRename(TopoNode node)
{
if (node.Kind != TopoNodeKind.Area) return;
_renamingKey = node.Key;
_renameBuffer = node.Label;
_renameError = null;
}
private void CancelRename()
{
_renamingKey = null;
_renameBuffer = string.Empty;
_renameError = null;
}
private async Task OnRenameKeyDown(KeyboardEventArgs e, TopoNode node)
{
if (e.Key == "Escape")
{
CancelRename();
}
else if (e.Key == "Enter")
{
await CommitRename(node);
}
}
private async Task CommitRename(TopoNode node)
{
if (string.IsNullOrWhiteSpace(_renameBuffer) || _renameBuffer.Trim() == node.Label)
{
CancelRename();
return;
}
var user = await GetCurrentUserAsync();
var result = await AreaService.UpdateAreaAsync(node.EntityId, _renameBuffer.Trim(), user);
if (result.IsSuccess)
{
CancelRename();
_toast.ShowSuccess($"Area renamed to '{result.Value.Name}'.");
await LoadDataAsync();
}
else
{
_renameError = result.Error;
}
}
// ---- Create-area dialog ----
private bool _showCreateAreaDialog;
private bool _createAreaRequireSitePicker;
private string _createAreaContextLabel = string.Empty;
private int? _createAreaSiteId;
private int? _createAreaParentId;
private string? _createAreaError;
private void OpenCreateAreaDialogRoot()
{
_createAreaRequireSitePicker = true;
_createAreaContextLabel = string.Empty;
_createAreaSiteId = null;
_createAreaParentId = null;
_createAreaError = null;
_showCreateAreaDialog = true;
}
private void OpenCreateAreaDialogForSite(int siteId)
{
var site = _sites.FirstOrDefault(s => s.Id == siteId);
_createAreaRequireSitePicker = false;
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} (root)";
_createAreaSiteId = siteId;
_createAreaParentId = null;
_createAreaError = null;
_showCreateAreaDialog = true;
}
private void OpenCreateAreaDialogForArea(int siteId, int parentAreaId, string parentLabel)
{
var site = _sites.FirstOrDefault(s => s.Id == siteId);
_createAreaRequireSitePicker = false;
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} → Parent: {parentLabel}";
_createAreaSiteId = siteId;
_createAreaParentId = parentAreaId;
_createAreaError = null;
_showCreateAreaDialog = true;
}
private async Task SubmitCreateArea((int SiteId, int? ParentAreaId, string Name) req)
{
_createAreaError = null;
if (req.SiteId == 0) { _createAreaError = "Select a site."; return; }
if (string.IsNullOrWhiteSpace(req.Name)) { _createAreaError = "Area name is required."; return; }
var user = await GetCurrentUserAsync();
var result = await AreaService.CreateAreaAsync(req.Name, req.SiteId, req.ParentAreaId, user);
if (result.IsSuccess)
{
_showCreateAreaDialog = false;
_toast.ShowSuccess($"Area '{result.Value.Name}' created.");
await LoadDataAsync();
}
else
{
_createAreaError = result.Error;
}
}
// ---- Move-area dialog ----
private bool _showMoveAreaDialog;
private int _moveAreaId;
private string _moveAreaName = string.Empty;
private int _moveAreaSiteId;
private int? _moveAreaCurrentParentId;
private string? _moveAreaError;
private void OpenMoveAreaDialog(TopoNode node)
{
_moveAreaId = node.EntityId;
_moveAreaName = node.Label;
_moveAreaSiteId = node.SiteId;
_moveAreaCurrentParentId = node.Area?.ParentAreaId;
_moveAreaError = null;
_showMoveAreaDialog = true;
}
private async Task SubmitMoveArea((int AreaId, int? NewParentId) req)
{
_moveAreaError = null;
var user = await GetCurrentUserAsync();
var result = await AreaService.MoveAreaAsync(req.AreaId, req.NewParentId, user);
if (result.IsSuccess)
{
_showMoveAreaDialog = false;
_toast.ShowSuccess($"Area '{_moveAreaName}' moved.");
await LoadDataAsync();
}
else
{
_moveAreaError = result.Error;
}
}
// ---- Move-instance dialog ----
private bool _showMoveInstanceDialog;
private int _moveInstanceId;
private string _moveInstanceName = string.Empty;
private int _moveInstanceSiteId;
private int? _moveInstanceCurrentAreaId;
private string? _moveInstanceError;
private void OpenMoveInstanceDialog(TopoNode node)
{
var inst = node.Instance!;
_moveInstanceId = inst.Id;
_moveInstanceName = inst.UniqueName;
_moveInstanceSiteId = inst.SiteId;
_moveInstanceCurrentAreaId = inst.AreaId;
_moveInstanceError = null;
_showMoveInstanceDialog = true;
}
private async Task SubmitMoveInstance((int InstanceId, int? NewAreaId) req)
{
_moveInstanceError = null;
var user = await GetCurrentUserAsync();
var result = await InstanceService.AssignToAreaAsync(req.InstanceId, req.NewAreaId, user);
if (result.IsSuccess)
{
_showMoveInstanceDialog = false;
_toast.ShowSuccess($"Instance '{_moveInstanceName}' moved.");
await LoadDataAsync();
}
else
{
_moveInstanceError = result.Error;
}
}
// ---- Area & instance deletion ----
private async Task DeleteArea(TopoNode node)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Area",
$"Delete area '{node.Label}'? This will fail if it has sub-areas or assigned instances.",
danger: true);
if (!confirmed) return;
_actionInProgress = true;
var user = await GetCurrentUserAsync();
var result = await AreaService.DeleteAreaAsync(node.EntityId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Area '{node.Label}' deleted.");
await LoadDataAsync();
}
else
{
_toast.ShowError(result.Error);
}
_actionInProgress = false;
}
private async Task DeleteInstance(Instance inst)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Instance",
$"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
danger: true);
if (!confirmed) return;
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deleted.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Delete failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
_actionInProgress = false;
}
// ---- Lifecycle actions ----
private async Task EnableInstance(Instance inst)
{
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' enabled.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Enable failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Enable failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DisableInstance(Instance inst)
{
var confirmed = await Dialog.ConfirmAsync(
"Disable Instance",
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
danger: true);
if (!confirmed) return;
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' disabled.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Disable failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Disable failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DeployInstance(Instance inst)
{
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Deploy failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Deploy failed: {ex.Message}");
}
_actionInProgress = false;
}
// ---- Diff modal ----
private async Task ShowDiff(Instance inst)
{
DeploymentComparisonResult? diffResult = null;
string? diffError = null;
try
{
var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
if (result.IsSuccess)
{
diffResult = result.Value;
}
else
{
diffError = result.Error;
}
}
catch (Exception ex)
{
diffError = $"Failed to load diff: {ex.Message}";
}
RenderFragment body = builder =>
{
if (diffError != null)
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "alert alert-danger");
builder.AddContent(2, diffError);
builder.CloseElement();
}
else if (diffResult != null)
{
var stale = diffResult.IsStale;
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "mb-2");
builder.OpenElement(2, "span");
builder.AddAttribute(3, "class", stale ? "badge bg-warning text-dark" : "badge bg-success");
builder.AddContent(4, stale ? "Stale — changes pending" : "Current");
builder.CloseElement();
builder.OpenElement(5, "span");
builder.AddAttribute(6, "class", "text-muted small ms-2");
builder.AddContent(7,
$"Deployed: {diffResult.DeployedRevisionHash[..8]} | " +
$"Current: {diffResult.CurrentRevisionHash[..8]} | " +
$"Deployed at: {diffResult.DeployedAt.LocalDateTime:yyyy-MM-dd HH:mm}");
builder.CloseElement();
builder.CloseElement();
builder.OpenElement(8, "p");
builder.AddAttribute(9, "class", "text-muted small mb-0");
builder.AddContent(10, stale
? "The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes."
: "No differences between deployed and current configuration.");
builder.CloseElement();
// DeploymentManager-018: render the structured diff sections so
// the operator sees WHAT changed, not just that the hash moved.
// Each section uses the same compact change-table idiom; the
// connection section surfaces standalone endpoint/protocol/
// failover drift that no per-attribute row would show (#10).
var d = diffResult.Diff;
if (d != null)
{
RenderChangeSection(builder, 100_000, "Attributes", d.AttributeChanges,
a => a.Value ?? "—");
RenderChangeSection(builder, 200_000, "Alarms", d.AlarmChanges,
a => $"P{a.PriorityLevel} · {a.TriggerType}");
RenderChangeSection(builder, 300_000, "Scripts", d.ScriptChanges,
s => s.TriggerType ?? "—");
RenderChangeSection(builder, 400_000, "Connections", d.ConnectionChanges,
c => FormatConnection(c));
}
}
};
await _diffDialog.ShowAsync($"Deployment Diff — {inst.UniqueName}", body);
}
// Compact summary of a connection's deployment-relevant fields for the diff
// table's Before/After cells. Surfaces all four fields ConnectionsEqual
// compares — protocol, primary endpoint config, failover retry count, and
// the backup endpoint — so a backup-only change doesn't show identical
// Before/After cells. The backup segment is omitted when there is no backup.
private static string FormatConnection(
ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.ConnectionConfig c)
{
var endpoint = string.IsNullOrWhiteSpace(c.ConfigurationJson) ? "—" : c.ConfigurationJson;
var summary = $"{c.Protocol} · {endpoint} · failover ×{c.FailoverRetryCount}";
if (!string.IsNullOrWhiteSpace(c.BackupConfigurationJson))
{
summary += $" · backup {c.BackupConfigurationJson}";
}
return summary;
}
// Renders one change section (a heading plus a Bootstrap change-table) for a
// set of diff entries, matching the deployment-diff idiom used elsewhere in
// the UI: table-sm/table-striped, a colored change badge, and Before/After
// text columns. Nothing is rendered when the section has no entries, so the
// four sections (attributes, alarms, scripts, connections) all read the same
// and only appear when they actually changed. seqBase values are spaced
// 100k apart so each section's per-row sequence numbers (13 per row) stay in
// a disjoint, ascending range no matter how many entries a section has.
private static void RenderChangeSection<T>(
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder,
int seqBase,
string heading,
IReadOnlyList<ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.DiffEntry<T>> entries,
Func<T, string> summarize)
{
if (entries.Count == 0)
return;
builder.OpenElement(seqBase, "div");
builder.AddAttribute(seqBase + 1, "class", "mt-3");
builder.OpenElement(seqBase + 2, "div");
builder.AddAttribute(seqBase + 3, "class", "fw-semibold small mb-1");
builder.AddContent(seqBase + 4, $"{heading} ({entries.Count})");
builder.CloseElement();
builder.OpenElement(seqBase + 5, "table");
builder.AddAttribute(seqBase + 6, "class", "table table-sm table-striped align-middle mb-0");
// Header row.
builder.OpenElement(seqBase + 7, "thead");
builder.OpenElement(seqBase + 8, "tr");
AppendHeaderCell(builder, seqBase + 9, "Name");
AppendHeaderCell(builder, seqBase + 12, "Change");
AppendHeaderCell(builder, seqBase + 15, "Before");
AppendHeaderCell(builder, seqBase + 18, "After");
builder.CloseElement(); // tr
builder.CloseElement(); // thead
builder.OpenElement(seqBase + 21, "tbody");
var rowSeq = seqBase + 22;
foreach (var entry in entries)
{
builder.OpenElement(rowSeq, "tr");
builder.OpenElement(rowSeq + 1, "td");
builder.AddContent(rowSeq + 2, entry.CanonicalName);
builder.CloseElement();
builder.OpenElement(rowSeq + 3, "td");
builder.OpenElement(rowSeq + 4, "span");
builder.AddAttribute(rowSeq + 5, "class", ChangeBadgeClass(entry.ChangeType));
builder.AddContent(rowSeq + 6, entry.ChangeType.ToString());
builder.CloseElement();
builder.CloseElement();
builder.OpenElement(rowSeq + 7, "td");
builder.AddAttribute(rowSeq + 8, "class", "small text-muted");
builder.AddContent(rowSeq + 9,
entry.OldValue is null ? "—" : summarize(entry.OldValue));
builder.CloseElement();
builder.OpenElement(rowSeq + 10, "td");
builder.AddAttribute(rowSeq + 11, "class", "small");
builder.AddContent(rowSeq + 12,
entry.NewValue is null ? "—" : summarize(entry.NewValue));
builder.CloseElement();
builder.CloseElement(); // tr
rowSeq += 13;
}
builder.CloseElement(); // tbody
builder.CloseElement(); // table
builder.CloseElement(); // div.mt-3
}
private static void AppendHeaderCell(
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder, int seq, string text)
{
builder.OpenElement(seq, "th");
builder.AddAttribute(seq + 1, "scope", "col");
builder.AddContent(seq + 2, text);
builder.CloseElement();
}
private static string ChangeBadgeClass(
ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.DiffChangeType changeType) => changeType switch
{
ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.DiffChangeType.Added => "badge bg-success",
ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening.DiffChangeType.Removed => "badge bg-danger",
_ => "badge bg-warning text-dark",
};
// ---- Dropdown option helpers ----
private IEnumerable<(int Id, string Label)> EnumerateSiteOptions()
{
foreach (var s in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
yield return (s.Id, s.Name);
}
private IEnumerable<(int Id, string Label, int SiteId)> EnumerateAreaOptionsForCreate()
{
foreach (var a in WalkAllSiteAreas())
yield return a;
}
private IEnumerable<(int Id, string Label, int SiteId)> WalkAllSiteAreas()
{
foreach (var site in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
{
foreach (var entry in WalkSiteHierarchy(site.Id, parentId: null, depth: 0, excludeAreaId: null))
yield return entry;
}
}
private IEnumerable<(int? Id, string Label)> EnumerateAreaOptionsForSite(int siteId)
{
yield return ((int?)null, "(No area — site root)");
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: null))
yield return ((int?)entry.Id, entry.Label);
}
private IEnumerable<(int? Id, string Label)> EnumerateAreaParentOptionsExcluding(int areaId, int siteId)
{
yield return ((int?)null, "(Site root)");
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: areaId))
yield return ((int?)entry.Id, entry.Label);
}
private IEnumerable<(int Id, string Label, int SiteId)> WalkSiteHierarchy(int siteId, int? parentId, int depth, int? excludeAreaId)
{
var levelAreas = _allAreas
.Where(a => a.SiteId == siteId && a.ParentAreaId == parentId)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase);
foreach (var a in levelAreas)
{
if (excludeAreaId.HasValue && a.Id == excludeAreaId.Value) continue;
yield return (a.Id, new string(' ', depth * 2) + a.Name, siteId);
foreach (var sub in WalkSiteHierarchy(siteId, a.Id, depth + 1, excludeAreaId))
yield return sub;
}
}
// 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();
}