Single /deployment/topology page replaces /deployment/instances (legacy URL preserved as a secondary @page directive) and the /admin/areas* CRUD pages. TreeView with Site → Area → Instance, V1–V7 visual guide (bi-building / bi-diagram-3 / bi-box), always-visible empty containers, search dim, F2 inline area rename, and right-click context menus per node kind (Add Area, Move to Area…, lifecycle actions, etc.). Adds AreaService.MoveAreaAsync with cycle prevention, same-site enforcement, and name-collision check at the new parent. Instance rename intentionally out of scope — UniqueName is the site-side actor identity, requires its own design pass.
879 lines
32 KiB
Plaintext
879 lines
32 KiB
Plaintext
@page "/deployment/topology"
|
|
@page "/deployment/instances"
|
|
@using ScadaLink.Security
|
|
@using ScadaLink.Commons.Entities.Instances
|
|
@using ScadaLink.Commons.Entities.Sites
|
|
@using ScadaLink.Commons.Entities.Templates
|
|
@using ScadaLink.Commons.Entities.Deployment
|
|
@using ScadaLink.Commons.Interfaces.Repositories
|
|
@using ScadaLink.Commons.Types.Enums
|
|
@using ScadaLink.DeploymentManager
|
|
@using ScadaLink.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 NavigationManager NavigationManager
|
|
@inject IJSRuntime JSRuntime
|
|
|
|
<div class="container-fluid mt-3">
|
|
<ToastNotification @ref="_toast" />
|
|
<ConfirmDialog @ref="_confirmDialog" />
|
|
|
|
<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
|
|
{
|
|
<h6 class="mb-2">Topology</h6>
|
|
<div class="d-flex align-items-center mb-2 gap-2">
|
|
<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" @onclick="LoadDataAsync">Refresh</button>
|
|
<button class="btn btn-outline-secondary" @onclick="() => _tree?.ExpandAll()">Expand</button>
|
|
<button class="btn btn-outline-secondary" @onclick="() => _tree?.CollapseAll()">Collapse</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="max-height: calc(100vh - 180px); 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>
|
|
|
|
<div class="text-muted small mt-2">
|
|
@_allInstances.Count instance(s) across @_sites.Count site(s).
|
|
</div>
|
|
|
|
@* Diff Modal — ported from Instances.razor *@
|
|
@if (_showDiffModal)
|
|
{
|
|
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Deployment Diff — @_diffInstanceName</h5>
|
|
<button type="button" class="btn-close" @onclick="() => _showDiffModal = false"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
@if (_diffLoading)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
}
|
|
else if (_diffError != null)
|
|
{
|
|
<div class="alert alert-danger">@_diffError</div>
|
|
}
|
|
else if (_diffResult != null)
|
|
{
|
|
<div class="mb-2">
|
|
<span class="badge @(_diffResult.IsStale ? "bg-warning text-dark" : "bg-success")">
|
|
@(_diffResult.IsStale ? "Stale — changes pending" : "Current")
|
|
</span>
|
|
<span class="text-muted small ms-2">
|
|
Deployed: @_diffResult.DeployedRevisionHash[..8]
|
|
| Current: @_diffResult.CurrentRevisionHash[..8]
|
|
| Deployed at: @_diffResult.DeployedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")
|
|
</span>
|
|
</div>
|
|
@if (!_diffResult.IsStale)
|
|
{
|
|
<p class="text-muted">No differences between deployed and current configuration.</p>
|
|
}
|
|
else
|
|
{
|
|
<p class="text-muted small">The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes.</p>
|
|
}
|
|
}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary btn-sm" @onclick="() => _showDiffModal = false">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
</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 ConfirmDialog _confirmDialog = default!;
|
|
|
|
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();
|
|
}
|
|
|
|
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
|
|
{
|
|
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
|
|
_sites = (await SiteRepository.GetAllSitesAsync()).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;"
|
|
@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)
|
|
{
|
|
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1" style="@labelStyle">
|
|
@(node.IsStale ? "Stale" : "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="() => 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 _confirmDialog.ShowAsync(
|
|
$"Delete area '{node.Label}'? This will fail if it has sub-areas or assigned instances.",
|
|
"Delete Area");
|
|
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 _confirmDialog.ShowAsync(
|
|
$"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
|
|
"Delete Instance");
|
|
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 _confirmDialog.ShowAsync(
|
|
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
|
|
"Disable Instance");
|
|
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 bool _showDiffModal;
|
|
private bool _diffLoading;
|
|
private string? _diffError;
|
|
private string _diffInstanceName = string.Empty;
|
|
private DeploymentComparisonResult? _diffResult;
|
|
|
|
private async Task ShowDiff(Instance inst)
|
|
{
|
|
_showDiffModal = true;
|
|
_diffLoading = true;
|
|
_diffError = null;
|
|
_diffResult = null;
|
|
_diffInstanceName = inst.UniqueName;
|
|
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}";
|
|
}
|
|
_diffLoading = false;
|
|
}
|
|
|
|
// ---- 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;
|
|
}
|
|
}
|
|
|
|
private async Task<string> GetCurrentUserAsync()
|
|
{
|
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
|
}
|
|
}
|