feat(ui/deployment): consolidate sites/areas/instances into Topology page
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.
This commit is contained in:
@@ -42,9 +42,6 @@
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/areas">Areas</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@@ -53,7 +50,7 @@
|
||||
<Authorized Context="deploymentContext">
|
||||
<li class="nav-section-header">Deployment</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/instances">Instances</NavLink>
|
||||
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
@page "/admin/areas/add"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||
<h4 class="mb-0">Add Area</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Site</label>
|
||||
<input type="text" class="form-control form-control-sm" value="@_siteName" readonly />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parent Area</label>
|
||||
<select class="form-select form-select-sm" @bind="_parentAreaId">
|
||||
<option value="0">(Root level)</option>
|
||||
@foreach (var area in _areas)
|
||||
{
|
||||
<option value="@area.Id">@GetAreaPath(area)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_name" placeholder="Area name" />
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery] public int SiteId { get; set; }
|
||||
[SupplyParameterFromQuery] public int ParentAreaId { get; set; }
|
||||
|
||||
private string _siteName = string.Empty;
|
||||
private List<Area> _areas = new();
|
||||
private int _parentAreaId;
|
||||
private string _name = string.Empty;
|
||||
private string? _formError;
|
||||
private string? _errorMessage;
|
||||
private bool _loading = true;
|
||||
private bool _saving;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var site = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.Id == SiteId);
|
||||
_siteName = site?.Name ?? $"Site #{SiteId}";
|
||||
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(SiteId)).ToList();
|
||||
_parentAreaId = ParentAreaId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private string GetAreaPath(Area area)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var current = area;
|
||||
while (current != null)
|
||||
{
|
||||
parts.Insert(0, current.Name);
|
||||
current = current.ParentAreaId.HasValue
|
||||
? _areas.FirstOrDefault(a => a.Id == current.ParentAreaId.Value)
|
||||
: null;
|
||||
}
|
||||
return string.Join(" / ", parts);
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
|
||||
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
var area = new Area(_name.Trim())
|
||||
{
|
||||
SiteId = SiteId,
|
||||
ParentAreaId = _parentAreaId == 0 ? null : _parentAreaId
|
||||
};
|
||||
await TemplateEngineRepository.AddAreaAsync(area);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/areas");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
@page "/admin/areas/{Id:int}/delete"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||
<h4 class="mb-0">Delete Area</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card mb-3" style="max-width: 700px;">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
You are about to delete area <strong>@_area!.Name</strong>.
|
||||
@if (_hasBlockingInstances)
|
||||
{
|
||||
<span class="text-danger">This area (or its children) has instances assigned. Remove or reassign instances before deleting.</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
<h6 class="mt-3">Area hierarchy with assigned instances:</h6>
|
||||
|
||||
<TreeView TItem="DeleteTreeNode" Items="_treeRoots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => n.Key"
|
||||
InitiallyExpanded="_ => true">
|
||||
<NodeContent Context="node">
|
||||
@if (node.Kind == DeleteNodeKind.Area)
|
||||
{
|
||||
<span class="@(node.HasInstances ? "text-danger fw-semibold" : "")">@node.Label</span>
|
||||
@if (node.HasInstances)
|
||||
{
|
||||
<span class="badge bg-danger ms-1">@node.InstanceCount instance(s)</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">@node.Label</span>
|
||||
}
|
||||
</NodeContent>
|
||||
<EmptyContent>
|
||||
<span class="text-muted fst-italic">No child areas.</span>
|
||||
</EmptyContent>
|
||||
</TreeView>
|
||||
|
||||
<div class="mt-3">
|
||||
@if (_hasBlockingInstances)
|
||||
{
|
||||
<button class="btn btn-danger btn-sm" disabled>Delete (blocked)</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-danger btn-sm" @onclick="Delete" disabled="@_deleting">Delete Area</button>
|
||||
}
|
||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm ms-2">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
record DeleteTreeNode(string Key, string Label, DeleteNodeKind Kind, List<DeleteTreeNode> Children,
|
||||
bool HasInstances = false, int InstanceCount = 0);
|
||||
enum DeleteNodeKind { Area, Instance }
|
||||
|
||||
private Area? _area;
|
||||
private List<DeleteTreeNode> _treeRoots = new();
|
||||
private bool _hasBlockingInstances;
|
||||
private bool _loading = true;
|
||||
private bool _deleting;
|
||||
private string? _errorMessage;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
|
||||
if (_area == null)
|
||||
{
|
||||
_errorMessage = $"Area #{Id} not found.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all areas for this site to build hierarchy
|
||||
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_area.SiteId)).ToList();
|
||||
var allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
|
||||
.Where(i => i.SiteId == _area.SiteId)
|
||||
.ToList();
|
||||
|
||||
var rootNode = BuildDeleteTree(_area, allAreas, allInstances);
|
||||
_treeRoots = new List<DeleteTreeNode> { rootNode };
|
||||
_hasBlockingInstances = HasAnyInstances(rootNode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private DeleteTreeNode BuildDeleteTree(Area area, List<Area> allAreas, List<Instance> allInstances)
|
||||
{
|
||||
var children = new List<DeleteTreeNode>();
|
||||
|
||||
// Add child areas recursively
|
||||
var childAreas = allAreas.Where(a => a.ParentAreaId == area.Id).OrderBy(a => a.Name);
|
||||
foreach (var child in childAreas)
|
||||
{
|
||||
children.Add(BuildDeleteTree(child, allAreas, allInstances));
|
||||
}
|
||||
|
||||
// Add instances assigned to this area
|
||||
var areaInstances = allInstances.Where(i => i.AreaId == area.Id).OrderBy(i => i.UniqueName);
|
||||
foreach (var inst in areaInstances)
|
||||
{
|
||||
children.Add(new DeleteTreeNode(
|
||||
Key: $"inst-{inst.Id}",
|
||||
Label: inst.UniqueName,
|
||||
Kind: DeleteNodeKind.Instance,
|
||||
Children: new()));
|
||||
}
|
||||
|
||||
var instanceCount = areaInstances.Count();
|
||||
return new DeleteTreeNode(
|
||||
Key: $"area-{area.Id}",
|
||||
Label: area.Name,
|
||||
Kind: DeleteNodeKind.Area,
|
||||
Children: children,
|
||||
HasInstances: instanceCount > 0,
|
||||
InstanceCount: instanceCount);
|
||||
}
|
||||
|
||||
private bool HasAnyInstances(DeleteTreeNode node)
|
||||
{
|
||||
if (node.Kind == DeleteNodeKind.Instance) return true;
|
||||
return node.Children.Any(HasAnyInstances);
|
||||
}
|
||||
|
||||
private async Task Delete()
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
$"Permanently delete area '{_area!.Name}' and all its child areas?",
|
||||
"Confirm Delete");
|
||||
if (!confirmed) return;
|
||||
|
||||
_deleting = true;
|
||||
try
|
||||
{
|
||||
// Delete child areas bottom-up (deepest first)
|
||||
await DeleteAreaRecursive(_area!);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/areas");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
_deleting = false;
|
||||
}
|
||||
|
||||
private async Task DeleteAreaRecursive(Area area)
|
||||
{
|
||||
// Load fresh children in case the collection wasn't populated
|
||||
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(area.SiteId)).ToList();
|
||||
var children = allAreas.Where(a => a.ParentAreaId == area.Id).ToList();
|
||||
foreach (var child in children)
|
||||
{
|
||||
await DeleteAreaRecursive(child);
|
||||
}
|
||||
await TemplateEngineRepository.DeleteAreaAsync(area.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
@page "/admin/areas/{Id:int}/edit"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||
<h4 class="mb-0">Edit Area</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_name" />
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private Area? _area;
|
||||
private string _name = string.Empty;
|
||||
private string? _formError;
|
||||
private string? _errorMessage;
|
||||
private bool _loading = true;
|
||||
private bool _saving;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
|
||||
if (_area == null)
|
||||
{
|
||||
_errorMessage = $"Area #{Id} not found.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_name = _area.Name;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load area: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
|
||||
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
_area!.Name = _name.Trim();
|
||||
await TemplateEngineRepository.UpdateAreaAsync(_area);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/areas");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
@page "/admin/areas"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h4 class="mb-3">Area Management</h4>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<TreeView TItem="AreaTreeNode" Items="_treeRoots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => n.Key"
|
||||
StorageKey="areas-tree">
|
||||
<NodeContent Context="node">
|
||||
@if (node.Kind == AreaNodeKind.Site)
|
||||
{
|
||||
<span class="fw-semibold">@node.Label</span>
|
||||
<span class="badge bg-secondary ms-1">@node.AreaCount</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@node.Label</span>
|
||||
}
|
||||
</NodeContent>
|
||||
<ContextMenu Context="node">
|
||||
@if (node.Kind == AreaNodeKind.Site)
|
||||
{
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}")'>
|
||||
Add Area
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}&parentAreaId={node.Area!.Id}")'>
|
||||
Add Child Area
|
||||
</button>
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/edit")'>
|
||||
Edit Area
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/delete")'>
|
||||
Delete Area
|
||||
</button>
|
||||
}
|
||||
</ContextMenu>
|
||||
<EmptyContent>
|
||||
<span class="text-muted fst-italic">No sites configured.</span>
|
||||
</EmptyContent>
|
||||
</TreeView>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
record AreaTreeNode(string Key, string Label, AreaNodeKind Kind, List<AreaTreeNode> Children,
|
||||
int SiteId, Area? Area = null, int AreaCount = 0);
|
||||
enum AreaNodeKind { Site, Area }
|
||||
|
||||
private List<Site> _sites = new();
|
||||
private List<AreaTreeNode> _treeRoots = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
_treeRoots = new();
|
||||
foreach (var site in _sites)
|
||||
{
|
||||
var areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id)).ToList();
|
||||
var rootAreas = areas.Where(a => a.ParentAreaId == null).OrderBy(a => a.Name);
|
||||
var children = rootAreas.Select(a => BuildAreaNode(a, site.Id)).ToList();
|
||||
_treeRoots.Add(new AreaTreeNode(
|
||||
Key: $"site-{site.Id}",
|
||||
Label: site.Name,
|
||||
Kind: AreaNodeKind.Site,
|
||||
Children: children,
|
||||
SiteId: site.Id,
|
||||
AreaCount: areas.Count));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load data: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private AreaTreeNode BuildAreaNode(Area area, int siteId)
|
||||
{
|
||||
var children = area.Children
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => BuildAreaNode(c, siteId))
|
||||
.ToList();
|
||||
return new AreaTreeNode(
|
||||
Key: $"area-{area.Id}",
|
||||
Label: area.Name,
|
||||
Kind: AreaNodeKind.Area,
|
||||
Children: children,
|
||||
SiteId: siteId,
|
||||
Area: area);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">New Area</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (RequireSitePicker)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_siteId">
|
||||
<option value="0">(Select a site)</option>
|
||||
@foreach (var opt in SiteOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Parent area</label>
|
||||
<select class="form-select form-select-sm" @bind="_parentAreaId">
|
||||
<option value="0">(Site root)</option>
|
||||
@foreach (var opt in ParentOptions.Where(o => SelectedSiteMatches(o)))
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted small mb-2">
|
||||
@ContextLabel
|
||||
</div>
|
||||
}
|
||||
<label class="form-label small">Name</label>
|
||||
<input class="form-control form-control-sm" placeholder="Area name" @bind="_name" />
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public bool RequireSitePicker { get; set; }
|
||||
[Parameter] public string ContextLabel { get; set; } = string.Empty;
|
||||
[Parameter] public int? SiteId { get; set; }
|
||||
[Parameter] public int? ParentAreaId { get; set; }
|
||||
[Parameter] public IEnumerable<(int Id, string Label)> SiteOptions { get; set; } = Array.Empty<(int, string)>();
|
||||
[Parameter] public IEnumerable<(int Id, string Label, int SiteId)> ParentOptions { get; set; } = Array.Empty<(int, string, int)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int SiteId, int? ParentAreaId, string Name)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private string _name = string.Empty;
|
||||
private int _siteId;
|
||||
private int _parentAreaId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_name = string.Empty;
|
||||
_siteId = SiteId ?? 0;
|
||||
_parentAreaId = ParentAreaId ?? 0;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private bool SelectedSiteMatches((int Id, string Label, int SiteId) opt) =>
|
||||
_siteId == 0 || opt.SiteId == _siteId;
|
||||
|
||||
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
var effectiveSite = RequireSitePicker ? _siteId : (SiteId ?? 0);
|
||||
var effectiveParent = RequireSitePicker
|
||||
? (_parentAreaId == 0 ? (int?)null : _parentAreaId)
|
||||
: ParentAreaId;
|
||||
await OnSubmit.InvokeAsync((effectiveSite, effectiveParent, _name.Trim()));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back to Instances</button>
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back to Topology</button>
|
||||
<h4 class="mb-0">Configure Instance</h4>
|
||||
</div>
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/instances");
|
||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a href="/deployment/instances" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||
<a href="/deployment/topology" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||
<h4 class="mb-0">Create Instance</h4>
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,9 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery] public int? SiteId { get; set; }
|
||||
[SupplyParameterFromQuery] public int? AreaId { get; set; }
|
||||
|
||||
private List<Site> _sites = new();
|
||||
private List<Template> _templates = new();
|
||||
private List<Area> _allAreas = new();
|
||||
@@ -98,6 +101,15 @@
|
||||
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
||||
_allAreas.AddRange(areas);
|
||||
}
|
||||
|
||||
if (SiteId is int sid && _sites.Any(s => s.Id == sid))
|
||||
{
|
||||
_createSiteId = sid;
|
||||
}
|
||||
if (AreaId is int aid && _allAreas.Any(a => a.Id == aid && a.SiteId == _createSiteId))
|
||||
{
|
||||
_createAreaId = aid;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -120,7 +132,7 @@
|
||||
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/deployment/instances");
|
||||
NavigationManager.NavigateTo("/deployment/topology");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -133,7 +145,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/instances");
|
||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
@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
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
||||
@inject DeploymentService DeploymentService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Instances</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>Create Instance</button>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Filters *@
|
||||
<div class="row mb-3 g-2">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_filterSiteId" @bind:after="ApplyFilters">
|
||||
<option value="0">All Sites</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.Id">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Template</label>
|
||||
<select class="form-select form-select-sm" @bind="_filterTemplateId" @bind:after="ApplyFilters">
|
||||
<option value="0">All Templates</option>
|
||||
@foreach (var tmpl in _templates)
|
||||
{
|
||||
<option value="@tmpl.Id">@tmpl.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Status</label>
|
||||
<select class="form-select form-select-sm" @bind="_filterStatus" @bind:after="ApplyFilters">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="NotDeployed">Not Deployed</option>
|
||||
<option value="Enabled">Enabled</option>
|
||||
<option value="Disabled">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Search</label>
|
||||
<input type="text" class="form-control form-control-sm" placeholder="Instance name..."
|
||||
@bind="_filterSearch" @bind:event="oninput" @bind:after="ApplyFilters" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TreeView @ref="_instanceTree" TItem="InstanceTreeNode" Items="_treeRoots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => n.Key"
|
||||
StorageKey="instances-tree"
|
||||
Selectable="true"
|
||||
SelectedKey="_selectedKey"
|
||||
SelectedKeyChanged="key => { _selectedKey = key; }">
|
||||
<NodeContent Context="node">
|
||||
@switch (node.Kind)
|
||||
{
|
||||
case InstanceNodeKind.Site:
|
||||
<span class="fw-semibold">@node.Label</span>
|
||||
break;
|
||||
case InstanceNodeKind.Area:
|
||||
<span class="text-secondary">@node.Label</span>
|
||||
break;
|
||||
case InstanceNodeKind.Instance:
|
||||
<span>@node.Label</span>
|
||||
<span class="badge @GetStateBadge(node.Instance!.State) ms-1">@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">
|
||||
@(node.IsStale ? "Stale" : "Current")
|
||||
</span>
|
||||
}
|
||||
break;
|
||||
}
|
||||
</NodeContent>
|
||||
<ContextMenu Context="node">
|
||||
@if (node.Kind == InstanceNodeKind.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>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
|
||||
disabled="@_actionInProgress">Delete</button>
|
||||
}
|
||||
</ContextMenu>
|
||||
<EmptyContent>
|
||||
<span class="text-muted fst-italic">No instances match the current filters.</span>
|
||||
</EmptyContent>
|
||||
</TreeView>
|
||||
|
||||
<div class="text-muted small mt-2">
|
||||
@_filteredInstances.Count instance(s) total
|
||||
</div>
|
||||
|
||||
@* Diff Modal *@
|
||||
@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 {
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
|
||||
record InstanceTreeNode(string Key, string Label, InstanceNodeKind Kind,
|
||||
List<InstanceTreeNode> Children, Instance? Instance = null,
|
||||
bool IsStale = false);
|
||||
enum InstanceNodeKind { Site, Area, Instance }
|
||||
|
||||
private List<Instance> _allInstances = new();
|
||||
private List<Instance> _filteredInstances = 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 int _filterSiteId;
|
||||
private int _filterTemplateId;
|
||||
private string _filterStatus = string.Empty;
|
||||
private string _filterSearch = string.Empty;
|
||||
|
||||
private List<InstanceTreeNode> _treeRoots = new();
|
||||
private TreeView<InstanceTreeNode> _instanceTree = default!;
|
||||
private object? _selectedKey;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||
|
||||
// Load areas for display
|
||||
_allAreas.Clear();
|
||||
foreach (var site in _sites)
|
||||
{
|
||||
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
||||
_allAreas.AddRange(areas);
|
||||
}
|
||||
|
||||
// Check staleness for deployed instances
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
ApplyFilters();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load instances: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
_filteredInstances = _allInstances.Where(i =>
|
||||
{
|
||||
if (_filterSiteId > 0 && i.SiteId != _filterSiteId) return false;
|
||||
if (_filterTemplateId > 0 && i.TemplateId != _filterTemplateId) return false;
|
||||
if (!string.IsNullOrEmpty(_filterStatus) && i.State.ToString() != _filterStatus) return false;
|
||||
if (!string.IsNullOrWhiteSpace(_filterSearch) &&
|
||||
!i.UniqueName.Contains(_filterSearch, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return true;
|
||||
}).OrderBy(i => i.UniqueName).ToList();
|
||||
|
||||
BuildTree();
|
||||
}
|
||||
|
||||
private void BuildTree()
|
||||
{
|
||||
_treeRoots = _sites.Select(site =>
|
||||
{
|
||||
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
|
||||
var siteInstances = _filteredInstances.Where(i => i.SiteId == site.Id).ToList();
|
||||
|
||||
var areaNodes = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
|
||||
|
||||
var unassigned = siteInstances
|
||||
.Where(i => i.AreaId == null)
|
||||
.Select(MakeInstanceNode)
|
||||
.ToList();
|
||||
|
||||
var children = areaNodes.Concat(unassigned).ToList();
|
||||
|
||||
return new InstanceTreeNode(
|
||||
Key: $"site-{site.Id}",
|
||||
Label: site.Name,
|
||||
Kind: InstanceNodeKind.Site,
|
||||
Children: children);
|
||||
})
|
||||
.Where(s => s.Children.Count > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private List<InstanceTreeNode> BuildAreaNodes(List<Area> allAreas,
|
||||
List<Instance> instances, int? parentId)
|
||||
{
|
||||
return allAreas
|
||||
.Where(a => a.ParentAreaId == parentId)
|
||||
.Select(area =>
|
||||
{
|
||||
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
|
||||
var areaInstances = instances
|
||||
.Where(i => i.AreaId == area.Id)
|
||||
.Select(MakeInstanceNode)
|
||||
.ToList();
|
||||
var children = childAreas.Concat(areaInstances).ToList();
|
||||
return new InstanceTreeNode(
|
||||
Key: $"area-{area.Id}",
|
||||
Label: area.Name,
|
||||
Kind: InstanceNodeKind.Area,
|
||||
Children: children);
|
||||
})
|
||||
.Where(a => a.Children.Count > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private InstanceTreeNode MakeInstanceNode(Instance inst) => new(
|
||||
Key: $"inst-{inst.Id}",
|
||||
Label: inst.UniqueName,
|
||||
Kind: InstanceNodeKind.Instance,
|
||||
Children: new(),
|
||||
Instance: inst,
|
||||
IsStale: _stalenessMap.GetValueOrDefault(inst.Id));
|
||||
|
||||
private string GetTemplateName(int templateId) =>
|
||||
_templates.FirstOrDefault(t => t.Id == templateId)?.Name ?? $"#{templateId}";
|
||||
|
||||
private string GetSiteName(int siteId) =>
|
||||
_sites.FirstOrDefault(s => s.Id == siteId)?.Name ?? $"#{siteId}";
|
||||
|
||||
private string GetAreaName(int areaId) =>
|
||||
_allAreas.FirstOrDefault(a => a.Id == areaId)?.Name ?? $"#{areaId}";
|
||||
|
||||
private static string GetStateBadge(InstanceState state) => state switch
|
||||
{
|
||||
InstanceState.Enabled => "bg-success",
|
||||
InstanceState.Disabled => "bg-secondary",
|
||||
InstanceState.NotDeployed => "bg-light text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Diff state
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Move area '@AreaName' to…</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<select class="form-select form-select-sm" @bind="_targetParentId">
|
||||
@foreach (var opt in ParentOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public int AreaId { get; set; }
|
||||
[Parameter] public string AreaName { get; set; } = string.Empty;
|
||||
[Parameter] public int? CurrentParentId { get; set; }
|
||||
[Parameter] public IEnumerable<(int? Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int?, string)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int AreaId, int? NewParentId)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private int? _targetParentId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_targetParentId = CurrentParentId;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||
|
||||
private async Task Submit() => await OnSubmit.InvokeAsync((AreaId, _targetParentId));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Move '@InstanceName' to…</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<select class="form-select form-select-sm" @bind="_targetAreaId">
|
||||
@foreach (var opt in AreaOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public int InstanceId { get; set; }
|
||||
[Parameter] public string InstanceName { get; set; } = string.Empty;
|
||||
[Parameter] public int? CurrentAreaId { get; set; }
|
||||
[Parameter] public IEnumerable<(int? Id, string Label)> AreaOptions { get; set; } = Array.Empty<(int?, string)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int InstanceId, int? NewAreaId)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private int? _targetAreaId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_targetAreaId = CurrentAreaId;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||
|
||||
private async Task Submit() => await OnSubmit.InvokeAsync((InstanceId, _targetAreaId));
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
@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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user