refactor(ui/sites): replace 10-col table with card grid + collapsible cluster panel
The dense table buried high-signal fields (name, identifier, connections) under four 80-character Akka/gRPC URLs truncated mid-string. Replace with a 2-column responsive card grid; cluster-node addresses now live in a collapsed disclosure with copy-to-clipboard. Adds client-side filter, empty/no-match states, kebab menu for less-frequent actions, and @key=site.Id to keep Bootstrap collapse state from leaking across cards when the filter changes.
This commit is contained in:
@@ -10,20 +10,33 @@
|
|||||||
@inject CommunicationService CommunicationService
|
@inject CommunicationService CommunicationService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Site Management</h4>
|
<h4 class="mb-0">Site Management</h4>
|
||||||
<div>
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-outline-warning btn-sm me-1" @onclick="DeployArtifactsToAllSites"
|
<div class="dropdown">
|
||||||
disabled="@_deploying">
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
||||||
@if (_deploying)
|
data-bs-toggle="dropdown" disabled="@_deploying">
|
||||||
{
|
@if (_deploying)
|
||||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
{
|
||||||
}
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
Deploy Artifacts to All Sites
|
}
|
||||||
|
Bulk actions
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item" @onclick="DeployArtifactsToAllSites">
|
||||||
|
Deploy Artifacts to All Sites
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>
|
||||||
|
+ Add Site
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>Add Site</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,70 +51,109 @@
|
|||||||
{
|
{
|
||||||
<div class="alert alert-danger">@_errorMessage</div>
|
<div class="alert alert-danger">@_errorMessage</div>
|
||||||
}
|
}
|
||||||
|
else if (_sites.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<p class="mb-3">No sites configured.</p>
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>
|
||||||
|
Add your first site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<table class="table table-sm table-striped table-hover">
|
<div class="mb-3" style="max-width: 320px;">
|
||||||
<thead class="table-dark">
|
<input class="form-control form-control-sm"
|
||||||
<tr>
|
placeholder="Filter by name or identifier…"
|
||||||
<th>ID</th>
|
@bind="_search" @bind:event="oninput" />
|
||||||
<th>Name</th>
|
</div>
|
||||||
<th>Identifier</th>
|
|
||||||
<th>Description</th>
|
@if (!FilteredSites.Any())
|
||||||
<th>Node A</th>
|
{
|
||||||
<th>Node B</th>
|
<p class="text-muted small">No sites match the filter.</p>
|
||||||
<th>gRPC Node A</th>
|
}
|
||||||
<th>gRPC Node B</th>
|
|
||||||
<th>Data Connections</th>
|
<div class="row g-3">
|
||||||
<th style="width: 260px;">Actions</th>
|
@foreach (var site in FilteredSites)
|
||||||
</tr>
|
{
|
||||||
</thead>
|
var conns = _siteConnections.GetValueOrDefault(site.Id);
|
||||||
<tbody>
|
var collapseId = $"cluster-{site.Id}";
|
||||||
@if (_sites.Count == 0)
|
<div class="col-lg-6 col-12" @key="site.Id">
|
||||||
{
|
<div class="card h-100">
|
||||||
<tr>
|
<div class="card-body">
|
||||||
<td colspan="10" class="text-muted text-center">No sites configured.</td>
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
</tr>
|
<div>
|
||||||
}
|
<h5 class="card-title mb-1">@site.Name</h5>
|
||||||
@foreach (var site in _sites)
|
<code class="small">@site.SiteIdentifier</code>
|
||||||
{
|
</div>
|
||||||
<tr>
|
<div class="d-flex gap-1">
|
||||||
<td>@site.Id</td>
|
<button class="btn btn-outline-primary btn-sm"
|
||||||
<td>@site.Name</td>
|
@onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>
|
||||||
<td><code>@site.SiteIdentifier</code></td>
|
Edit
|
||||||
<td class="text-muted small">@(site.Description ?? "—")</td>
|
</button>
|
||||||
<td class="small text-truncate" style="max-width: 200px;" title="@site.NodeAAddress">@(site.NodeAAddress ?? "—")</td>
|
<div class="dropdown">
|
||||||
<td class="small text-truncate" style="max-width: 200px;" title="@site.NodeBAddress">@(site.NodeBAddress ?? "—")</td>
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
<td class="small text-truncate" style="max-width: 200px;" title="@site.GrpcNodeAAddress">@(site.GrpcNodeAAddress ?? "—")</td>
|
data-bs-toggle="dropdown" aria-label="More actions">⋮</button>
|
||||||
<td class="small text-truncate" style="max-width: 200px;" title="@site.GrpcNodeBAddress">@(site.GrpcNodeBAddress ?? "—")</td>
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<td>
|
<li>
|
||||||
@{
|
<button class="dropdown-item"
|
||||||
var conns = _siteConnections.GetValueOrDefault(site.Id);
|
@onclick="() => DeployArtifacts(site)"
|
||||||
}
|
disabled="@_deploying">
|
||||||
@if (conns != null && conns.Count > 0)
|
Deploy Artifacts
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item text-danger"
|
||||||
|
@onclick="() => DeleteSite(site)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
@(string.IsNullOrWhiteSpace(site.Description) ? "—" : site.Description)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="small text-muted mb-1">Data connections</div>
|
||||||
|
@if (conns is { Count: > 0 })
|
||||||
{
|
{
|
||||||
@foreach (var conn in conns)
|
<ul class="list-unstyled mb-3">
|
||||||
{
|
@foreach (var c in conns)
|
||||||
<span class="badge bg-info text-dark me-1">@conn.Name (@conn.Protocol)</span>
|
{
|
||||||
}
|
<li class="mb-1">
|
||||||
|
<span class="badge bg-info text-dark me-1">@c.Protocol</span>
|
||||||
|
@c.Name
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted small">None</span>
|
<p class="text-muted small fst-italic mb-3">No connections.</p>
|
||||||
}
|
}
|
||||||
</td>
|
|
||||||
<td>
|
<button class="btn btn-link btn-sm p-0 text-decoration-none"
|
||||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
data-bs-toggle="collapse"
|
||||||
@onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>Edit</button>
|
data-bs-target="@($"#{collapseId}")"
|
||||||
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
|
aria-expanded="false">
|
||||||
@onclick="() => DeployArtifacts(site)"
|
Cluster nodes (Akka, gRPC)
|
||||||
disabled="@_deploying">Deploy Artifacts</button>
|
</button>
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
<div class="collapse mt-2" id="@collapseId">
|
||||||
@onclick="() => DeleteSite(site)">Delete</button>
|
@ClusterRow("Node A", site.NodeAAddress)
|
||||||
</td>
|
@ClusterRow("Node B", site.NodeBAddress)
|
||||||
</tr>
|
@ClusterRow("gRPC A", site.GrpcNodeAAddress)
|
||||||
}
|
@ClusterRow("gRPC B", site.GrpcNodeBAddress)
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,10 +170,18 @@
|
|||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
|
|
||||||
private bool _deploying;
|
private bool _deploying;
|
||||||
|
private string _search = "";
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
private ToastNotification _toast = default!;
|
||||||
private ConfirmDialog _confirmDialog = default!;
|
private ConfirmDialog _confirmDialog = default!;
|
||||||
|
|
||||||
|
private IEnumerable<Site> FilteredSites =>
|
||||||
|
string.IsNullOrWhiteSpace(_search)
|
||||||
|
? _sites
|
||||||
|
: _sites.Where(s =>
|
||||||
|
(s.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||||
|
(s.SiteIdentifier?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await LoadDataAsync();
|
await LoadDataAsync();
|
||||||
@@ -225,4 +285,36 @@
|
|||||||
_deploying = false;
|
_deploying = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private RenderFragment ClusterRow(string label, string? address) => __builder =>
|
||||||
|
{
|
||||||
|
<div class="row g-1 align-items-center mb-1">
|
||||||
|
<div class="col-2 small text-muted">@label</div>
|
||||||
|
<div class="col-9">
|
||||||
|
<code class="small d-block text-truncate" title="@address">
|
||||||
|
@(string.IsNullOrWhiteSpace(address) ? "—" : address)
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 text-end">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(address))
|
||||||
|
{
|
||||||
|
<button class="btn btn-link btn-sm p-0"
|
||||||
|
@onclick="() => CopyAsync(address!)" title="Copy">📋</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task CopyAsync(string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
||||||
|
_toast.ShowSuccess("Copied to clipboard.");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_toast.ShowError("Copy failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user