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>
</div> </div>
<table class="table table-sm table-striped table-hover"> <TreeView @ref="_instanceTree" TItem="InstanceTreeNode" Items="_treeRoots"
<thead class="table-dark"> ChildrenSelector="n => n.Children"
<tr> HasChildrenSelector="n => n.Children.Count > 0"
<th>Instance Name</th> KeySelector="n => n.Key"
<th>Template</th> StorageKey="instances-tree"
<th>Site</th> Selectable="true"
<th>Area</th> SelectedKey="_selectedKey"
<th>Status</th> SelectedKeyChanged="key => { _selectedKey = key; }">
<th>Staleness</th> <NodeContent Context="node">
<th style="width: 240px;">Actions</th> @switch (node.Kind)
</tr>
</thead>
<tbody>
@if (_filteredInstances.Count == 0)
{ {
<tr> case InstanceNodeKind.Site:
<td colspan="7" class="text-muted text-center">No instances match the current filters.</td> <span class="fw-semibold">@node.Label</span>
</tr> 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;
} }
@foreach (var inst in _pagedInstances) </NodeContent>
<ContextMenu Context="node">
@if (node.Kind == InstanceNodeKind.Instance)
{ {
<tr> var inst = node.Instance!;
<td><strong>@inst.UniqueName</strong></td> var isStale = node.IsStale;
<td>@GetTemplateName(inst.TemplateId)</td> <button class="dropdown-item" @onclick="() => DeployInstance(inst)"
<td>@GetSiteName(inst.SiteId)</td> disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
<td>@(inst.AreaId.HasValue ? GetAreaName(inst.AreaId.Value) : "—")</td> @if (inst.State == InstanceState.Enabled)
<td>
<span class="badge @GetStateBadge(inst.State)">@inst.State</span>
</td>
<td>
@{
var isStale = _stalenessMap.GetValueOrDefault(inst.Id);
}
@if (inst.State == InstanceState.NotDeployed)
{
<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>
@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>
}
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="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>
}
</tbody>
</table>
@* 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; <button class="dropdown-item" @onclick="() => DisableInstance(inst)"
<li class="page-item @(page == _currentPage ? "active" : "")"> disabled="@_actionInProgress">Disable</button>
<button class="page-link" @onclick="() => GoToPage(page)">@(page)</button>
</li>
} }
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")"> else if (inst.State == InstanceState.Disabled)
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button> {
</li> <button class="dropdown-item" @onclick="() => EnableInstance(inst)"
</ul> disabled="@_actionInProgress">Enable</button>
</nav> }
} <button class="dropdown-item"
<div class="text-muted small"> @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 @_filteredInstances.Count instance(s) total
</div> </div>
@@ -230,9 +199,13 @@
return authState.User.FindFirst("Username")?.Value ?? "unknown"; 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> _allInstances = new();
private List<Instance> _filteredInstances = new(); private List<Instance> _filteredInstances = new();
private List<Instance> _pagedInstances = new();
private List<Site> _sites = new(); private List<Site> _sites = new();
private List<Template> _templates = new(); private List<Template> _templates = new();
private List<Area> _allAreas = new(); private List<Area> _allAreas = new();
@@ -246,9 +219,9 @@
private string _filterStatus = string.Empty; private string _filterStatus = string.Empty;
private string _filterSearch = string.Empty; private string _filterSearch = string.Empty;
private int _currentPage = 1; private List<InstanceTreeNode> _treeRoots = new();
private int _totalPages; private TreeView<InstanceTreeNode> _instanceTree = default!;
private const int PageSize = 25; private object? _selectedKey;
private ToastNotification _toast = default!; private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!; private ConfirmDialog _confirmDialog = default!;
@@ -312,26 +285,66 @@
return true; return true;
}).OrderBy(i => i.UniqueName).ToList(); }).OrderBy(i => i.UniqueName).ToList();
_totalPages = Math.Max(1, (int)Math.Ceiling(_filteredInstances.Count / (double)PageSize)); BuildTree();
if (_currentPage > _totalPages) _currentPage = 1;
UpdatePage();
} }
private void GoToPage(int page) private void BuildTree()
{ {
if (page < 1 || page > _totalPages) return; _treeRoots = _sites.Select(site =>
_currentPage = page; {
UpdatePage(); 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 void UpdatePage() private List<InstanceTreeNode> BuildAreaNodes(List<Area> allAreas,
List<Instance> instances, int? parentId)
{ {
_pagedInstances = _filteredInstances return allAreas
.Skip((_currentPage - 1) * PageSize) .Where(a => a.ParentAreaId == parentId)
.Take(PageSize) .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(); .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) => private string GetTemplateName(int templateId) =>
_templates.FirstOrDefault(t => t.Id == templateId)?.Name ?? $"#{templateId}"; _templates.FirstOrDefault(t => t.Id == templateId)?.Name ?? $"#{templateId}";