refactor(ui): replace instances table with hierarchical TreeView (Site → Area → Instance)

This commit is contained in:
Joseph Doherty
2026-03-23 02:40:44 -04:00
parent 71894f4ba9
commit f1537b62ca

View File

@@ -75,100 +75,69 @@
</div>
</div>
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Instance Name</th>
<th>Template</th>
<th>Site</th>
<th>Area</th>
<th>Status</th>
<th>Staleness</th>
<th style="width: 240px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_filteredInstances.Count == 0)
<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)
{
<tr>
<td colspan="7" class="text-muted text-center">No instances match the current filters.</td>
</tr>
}
@foreach (var inst in _pagedInstances)
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)
{
<tr>
<td><strong>@inst.UniqueName</strong></td>
<td>@GetTemplateName(inst.TemplateId)</td>
<td>@GetSiteName(inst.SiteId)</td>
<td>@(inst.AreaId.HasValue ? GetAreaName(inst.AreaId.Value) : "—")</td>
<td>
<span class="badge @GetStateBadge(inst.State)">@inst.State</span>
</td>
<td>
@{
var isStale = _stalenessMap.GetValueOrDefault(inst.Id);
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1">
@(node.IsStale ? "Stale" : "Current")
</span>
}
@if (inst.State == InstanceState.NotDeployed)
break;
}
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == InstanceNodeKind.Instance)
{
<span class="text-muted small">—</span>
}
else if (isStale)
{
<span class="badge bg-warning text-dark" title="Template changes pending">Stale</span>
}
else
{
<span class="badge bg-light text-dark">Current</span>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => DeployInstance(inst)" disabled="@_actionInProgress"
title="Flatten template and send config to site">@(isStale ? "Redeploy" : "Deploy")</button>
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="btn btn-outline-warning btn-sm py-0 px-1 me-1"
@onclick="() => DisableInstance(inst)" disabled="@_actionInProgress">Disable</button>
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
disabled="@_actionInProgress">Disable</button>
}
else if (inst.State == InstanceState.Disabled)
{
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
@onclick="() => EnableInstance(inst)" disabled="@_actionInProgress">Enable</button>
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
disabled="@_actionInProgress">Enable</button>
}
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>Configure</button>
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
@onclick="() => ShowDiff(inst)" disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteInstance(inst)" disabled="@_actionInProgress">Delete</button>
</td>
</tr>
<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>
}
</tbody>
</table>
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No instances match the current filters.</span>
</EmptyContent>
</TreeView>
@* Pagination *@
@if (_totalPages > 1)
{
<nav>
<ul class="pagination pagination-sm justify-content-end">
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage - 1)">Previous</button>
</li>
@for (int i = 1; i <= _totalPages; i++)
{
var page = i;
<li class="page-item @(page == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(page)">@(page)</button>
</li>
}
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button>
</li>
</ul>
</nav>
}
<div class="text-muted small">
<div class="text-muted small mt-2">
@_filteredInstances.Count instance(s) total
</div>
@@ -230,9 +199,13 @@
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<Instance> _pagedInstances = new();
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
@@ -246,9 +219,9 @@
private string _filterStatus = string.Empty;
private string _filterSearch = string.Empty;
private int _currentPage = 1;
private int _totalPages;
private const int PageSize = 25;
private List<InstanceTreeNode> _treeRoots = new();
private TreeView<InstanceTreeNode> _instanceTree = default!;
private object? _selectedKey;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
@@ -312,26 +285,66 @@
return true;
}).OrderBy(i => i.UniqueName).ToList();
_totalPages = Math.Max(1, (int)Math.Ceiling(_filteredInstances.Count / (double)PageSize));
if (_currentPage > _totalPages) _currentPage = 1;
UpdatePage();
BuildTree();
}
private void GoToPage(int page)
private void BuildTree()
{
if (page < 1 || page > _totalPages) return;
_currentPage = page;
UpdatePage();
}
_treeRoots = _sites.Select(site =>
{
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
var siteInstances = _filteredInstances.Where(i => i.SiteId == site.Id).ToList();
private void UpdatePage()
{
_pagedInstances = _filteredInstances
.Skip((_currentPage - 1) * PageSize)
.Take(PageSize)
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}";