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:
Joseph Doherty
2026-05-11 22:03:55 -04:00
parent b2eddd9713
commit f3386d0278
18 changed files with 1857 additions and 1122 deletions

View File

@@ -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";
}
}