refactor(ui): replace instances table with hierarchical TreeView (Site → Area → Instance)
This commit is contained in:
@@ -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}";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user