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,928 @@
|
||||
@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();
|
||||
}
|
||||
};
|
||||
|
||||
await _diffDialog.ShowAsync($"Deployment Diff — {inst.UniqueName}", body);
|
||||
}
|
||||
|
||||
// ---- 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();
|
||||
}
|
||||
Reference in New Issue
Block a user