929 lines
34 KiB
Plaintext
929 lines
34 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 ScadaLink.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();
|
|
}
|