feat(centralui): server-side paging + server-computed status counts on Deployments (R3)
The Deployment Status page client-materialized the whole deployment list. It read EVERY DeploymentRecord — an insert-only table, one row per deploy attempt for the retention window — plus EVERY Instance, then site-scoped, sorted, counted the four status tiles and sliced a 25-row page in the Blazor circuit's memory. That ran on first render AND on every IDeploymentStatusNotifier push, so the cost scaled with the age of the system rather than the size of the page. All four jobs move into SQL: - `IDeploymentManagerRepository.QueryDeploymentListPageAsync(filter, page, size)` returns one page of `DeploymentListRow` — DeploymentRecord INNER JOINed to Instance, so the instance display name and site travel with the rows that need them — plus the total count of the filtered set. The join is exact: the FK is Restrict and DeleteInstanceAsync removes the records first, so no orphan exists. - `GetDeploymentStatusCountsAsync(filter)` returns the tile counts from ONE grouped aggregation, deliberately ignoring the filter's Status: the tiles are the status BREAKDOWN of the filtered set, so honouring it would zero three of four tiles the moment an operator clicked one. - Site scoping runs in the query as `SiteIdScope` resolved through the record's instance (DeploymentRecord has no SiteId of its own). An EMPTY grant stays a real filter matching nothing, never "unconstrained". - The now-callerless whole-table `GetAllDeploymentRecordsAsync` is deleted. OFFSET paging, not the Audit Log's keyset cursor, and deliberately so: this page's pager is numbered and jump-to-any-page, so it needs a page count, which only a total can give it — a keyset cursor can express neither, and the total is required for the tiles regardless. The deep-offset cost that pushes high-volume tables to keyset is bounded here by the terminal-record retention purge, unlike the 365-day AuditLog. This mirrors the Notification Outbox, offset-paged for the same reason. Ordering is DeployedAt DESC, Id DESC — the Id tie-break is load-bearing, because DeployedAt ties on rapid redeploys and an unstable sort key makes offset paging repeat or drop rows. UI: the four status tiles become the status filter (click to apply, click again to clear, aria-pressed, phrasing-only content so a <button> stays valid), plus a free-text search matched DB-side against instance name, deployment id, revision hash and initiating user. Search is TRAILING-edge debounced at 500ms — the same Timer + lock + _disposed idiom as the existing leading-edge push coalescer, minus the leading edge, because a search box must not query on the first keystroke. A filter change resets to page 1; a page past the end falls back to the last real page. Bootstrap only, existing PagerWindow pager retained. The WP2.4 push coalescing is unchanged and still earns its keep: server paging shrank what a reload costs, not how many arrive — it now bounds database round-trips rather than table scans. Tests: 19 new SQLite repository tests (paging slice + total, tie-break stability across pages, page/size clamping, past-the-end, the joined projection, every filter dimension incl. the empty-scope security case, and the grouped counts' status-blind contract); 15 new bUnit page tests (page-1 request, server total drives the pager, Next re-queries, tiles show server counts not page counts, tile filter + toggle, system-wide vs site-scoped scope push, debounce collapses a keystroke burst to one query, clear-filters, dispose with an armed timer). The two existing Deployments suites re-point their reload assertions at the new query. Doc: Component-CentralUI.md Deployment section — the "no server-side paging" known residual is replaced by the shipped design.
This commit is contained in:
+258
-86
@@ -1,12 +1,10 @@
|
||||
@page "/deployment/deployments"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
|
||||
@inject ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier DeploymentStatusNotifier
|
||||
@implements IDisposable
|
||||
@@ -23,6 +21,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Status tiles ──────────────────────────────────────────────────────────
|
||||
Counts come from ONE server-side grouped aggregation over the same filtered
|
||||
set the table is drawn from — not from the rows on screen — so they stay
|
||||
whole-fleet totals while the table shows a single page. Each tile doubles as
|
||||
the status filter; the active one is pressed and clicking it again clears. *@
|
||||
<div class="row g-2 mb-3">
|
||||
@* Each tile is a real <button> so it is keyboard-reachable and announces its
|
||||
pressed state. Its contents are PHRASING content only (span/small, not
|
||||
div/h4): a button's content model forbids flow content, and the .card /
|
||||
.card-body classes are carried by spans made block-level with d-block so
|
||||
the Bootstrap card look is unchanged. *@
|
||||
@foreach (var tile in StatusTiles)
|
||||
{
|
||||
var isActive = _statusFilter == tile.Status;
|
||||
<div class="col-lg col-md-4 col-6">
|
||||
<button type="button"
|
||||
class="card w-100 h-100 p-0 border-@tile.Variant @(isActive ? "border-2 shadow-sm" : "")"
|
||||
data-test="@tile.TestId"
|
||||
aria-pressed="@(isActive ? "true" : "false")"
|
||||
aria-label="@($"Filter by {tile.Label}: {CountFor(tile.Status)}")"
|
||||
@onclick="() => SetStatusFilterAsync(tile.Status)">
|
||||
<span class="card-body text-center py-2 d-block">
|
||||
<span class="h4 mb-0 d-block text-@tile.Variant">@CountFor(tile.Status)</span>
|
||||
<small class="text-muted">@tile.Label</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="col-lg col-md-4 col-6">
|
||||
<button type="button"
|
||||
class="card w-100 h-100 p-0 border-secondary @(_statusFilter is null ? "border-2 shadow-sm" : "")"
|
||||
data-test="deploy-tile-all"
|
||||
aria-pressed="@(_statusFilter is null ? "true" : "false")"
|
||||
aria-label="@($"Show all statuses: {_counts.Total}")"
|
||||
@onclick="() => SetStatusFilterAsync(null)">
|
||||
<span class="card-body text-center py-2 d-block">
|
||||
<span class="h4 mb-0 d-block">@_counts.Total</span>
|
||||
<small class="text-muted">All</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Filters ── *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-5 col-12">
|
||||
<label class="form-label small mb-1" for="dep-search">Search</label>
|
||||
<input id="dep-search" type="search" class="form-control form-control-sm"
|
||||
placeholder="Instance, deployment id, revision or user"
|
||||
data-test="deploy-search"
|
||||
value="@_searchInput"
|
||||
@oninput="OnSearchInput" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary btn-sm" data-test="deploy-clear-filters"
|
||||
disabled="@(!CanClearFilters)" @onclick="ClearFiltersAsync">
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
<div class="col text-end">
|
||||
<span class="small text-muted" data-test="deploy-result-count">
|
||||
@_totalCount matching @(_totalCount == 1 ? "deployment" : "deployments")
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
@@ -33,46 +101,10 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Summary cards *@
|
||||
<div class="row mb-3">
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-warning">@_records.Count(r => r.Status == DeploymentStatus.Pending)</h4>
|
||||
<small class="text-muted">Pending</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-info">@_records.Count(r => r.Status == DeploymentStatus.InProgress)</h4>
|
||||
<small class="text-muted">In Progress</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-success">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-success">@_records.Count(r => r.Status == DeploymentStatus.Success)</h4>
|
||||
<small class="text-muted">Successful</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-danger">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-danger">@_records.Count(r => r.Status == DeploymentStatus.Failed)</h4>
|
||||
<small class="text-muted">Failed</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_records.Count == 0)
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-0">No deployments recorded.</p>
|
||||
<p class="mb-0">@(HasActiveFilter ? "No deployments match the current filters." : "No deployments recorded.")</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -90,7 +122,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var record in _pagedRecords)
|
||||
@foreach (var record in _rows)
|
||||
{
|
||||
var rowId = $"deploy-row-{record.DeploymentId}";
|
||||
var errorCollapseId = $"deploy-err-{record.DeploymentId}";
|
||||
@@ -108,7 +140,7 @@
|
||||
<div class="font-monospace small text-muted" title="@record.RevisionHash">@revShort</div>
|
||||
}
|
||||
</td>
|
||||
<td>@GetInstanceName(record.InstanceId)</td>
|
||||
<td>@record.InstanceUniqueName</td>
|
||||
<td>
|
||||
@if (isFailed)
|
||||
{
|
||||
@@ -171,7 +203,7 @@
|
||||
<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>
|
||||
<button class="page-link" data-test="deploy-page-prev" @onclick="() => GoToPageAsync(_currentPage - 1)">Previous</button>
|
||||
</li>
|
||||
@foreach (var page in ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared.PagerWindow.Build(_currentPage, _totalPages))
|
||||
{
|
||||
@@ -185,12 +217,12 @@
|
||||
{
|
||||
var p = page;
|
||||
<li class="page-item @(p == _currentPage ? "active" : "")">
|
||||
<button class="page-link" @onclick="() => GoToPage(p)">@(p)</button>
|
||||
<button class="page-link" @onclick="() => GoToPageAsync(p)">@(p)</button>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
|
||||
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button>
|
||||
<button class="page-link" data-test="deploy-page-next" @onclick="() => GoToPageAsync(_currentPage + 1)">Next</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -199,9 +231,25 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<DeploymentRecord> _records = new();
|
||||
private List<DeploymentRecord> _pagedRecords = new();
|
||||
private Dictionary<int, string> _instanceNames = new();
|
||||
// ── Server-side paging (residual R3) ──────────────────────────────────────
|
||||
// The page used to read EVERY deployment record and EVERY instance, then
|
||||
// site-scope, sort, count the tiles and slice a 25-row page in the circuit's
|
||||
// memory — on first render AND on every deployment-status push. The cost
|
||||
// scaled with the age of the system (DeploymentRecords is insert-only, one row
|
||||
// per deploy attempt) rather than the size of the page. All four of those jobs
|
||||
// are now SQL: QueryDeploymentListPageAsync returns one page joined to its
|
||||
// instances plus the filtered total, and GetDeploymentStatusCountsAsync returns
|
||||
// the tile counts from one grouped aggregation.
|
||||
//
|
||||
// Paging is OFFSET, not the Audit Log's keyset cursor, because this page's
|
||||
// pager is numbered and jump-to-any-page: it needs a page count, which only a
|
||||
// total can give it, and a keyset cursor cannot express either. The total is
|
||||
// required for the tiles regardless. See QueryDeploymentListPageAsync's
|
||||
// remarks for the full rationale.
|
||||
private IReadOnlyList<DeploymentListRow> _rows = Array.Empty<DeploymentListRow>();
|
||||
private DeploymentStatusCounts _counts = DeploymentStatusCounts.Empty;
|
||||
private int _totalCount;
|
||||
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private bool _autoRefresh = true;
|
||||
@@ -211,6 +259,44 @@
|
||||
private int _totalPages;
|
||||
private const int PageSize = 25;
|
||||
|
||||
// ── Filters ───────────────────────────────────────────────────────────────
|
||||
private DeploymentStatus? _statusFilter;
|
||||
|
||||
/// <summary>Raw contents of the search box, updated on every keystroke.</summary>
|
||||
private string _searchInput = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The search term the last query actually ran with. Distinct from
|
||||
/// <see cref="_searchInput"/> so the debounce window can absorb keystrokes
|
||||
/// without the rendered result count implying a query that has not run.
|
||||
/// </summary>
|
||||
private string _appliedSearch = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the CURRENTLY RENDERED result set is constrained. Keyed on the
|
||||
/// applied search, not the box contents, so the empty-state message describes
|
||||
/// the query that actually ran.
|
||||
/// </summary>
|
||||
private bool HasActiveFilter => _statusFilter is not null || !string.IsNullOrWhiteSpace(_appliedSearch);
|
||||
|
||||
/// <summary>
|
||||
/// Whether "Clear filters" has anything to clear. Keyed on the box CONTENTS as
|
||||
/// well, so the escape hatch is live the moment the operator types — it must not
|
||||
/// be dead for the 500 ms the debounce window is still open.
|
||||
/// </summary>
|
||||
private bool CanClearFilters => HasActiveFilter || !string.IsNullOrWhiteSpace(_searchInput);
|
||||
|
||||
/// <summary>Tile definitions, in display order. The "All" tile is rendered separately.</summary>
|
||||
private static readonly (DeploymentStatus Status, string Label, string Variant, string TestId)[] StatusTiles =
|
||||
{
|
||||
(DeploymentStatus.Pending, "Pending", "warning", "deploy-tile-pending"),
|
||||
(DeploymentStatus.InProgress, "In Progress", "info", "deploy-tile-inprogress"),
|
||||
(DeploymentStatus.Success, "Successful", "success", "deploy-tile-success"),
|
||||
(DeploymentStatus.Failed, "Failed", "danger", "deploy-tile-failed"),
|
||||
};
|
||||
|
||||
private int CountFor(DeploymentStatus status) => _counts.For(status);
|
||||
|
||||
// CentralUI-022: IDeploymentStatusNotifier is a process singleton that
|
||||
// raises StatusChanged on the DeploymentManager service thread. Dispose()
|
||||
// unsubscribes, but the notifier can read its subscriber list and begin
|
||||
@@ -237,12 +323,13 @@
|
||||
}
|
||||
|
||||
// ── Push coalescing (arch-review WP2.4) ───────────────────────────────────
|
||||
// One reload = every deployment record + every instance, re-read and re-filtered.
|
||||
// The notifier fires per status WRITE, so a site deploy of N instances produced
|
||||
// 2N+ of those full reloads back to back, per circuit. The reload is now
|
||||
// leading-edge debounced: the first push after an idle gap still reloads
|
||||
// immediately (a single deployment stays as responsive as before), and every
|
||||
// push inside the window is absorbed into ONE trailing reload.
|
||||
// 2N+ back-to-back reloads per circuit. The reload is leading-edge debounced:
|
||||
// the first push after an idle gap still reloads immediately (a single
|
||||
// deployment stays as responsive as before), and every push inside the window
|
||||
// is absorbed into ONE trailing reload. Server-side paging shrank what a reload
|
||||
// costs, but it did not change how MANY arrive — the coalescing still earns its
|
||||
// keep, and now bounds round-trips to the database rather than table scans.
|
||||
private const int ReloadDebounceMs = 500;
|
||||
|
||||
private DateTimeOffset _lastReloadAt = DateTimeOffset.MinValue;
|
||||
@@ -296,6 +383,52 @@
|
||||
|
||||
private readonly object _coalesceLock = new();
|
||||
|
||||
// ── Filter debounce ───────────────────────────────────────────────────────
|
||||
// Same Timer + lock + _disposed idiom as the push coalescer above, but TRAILING
|
||||
// ONLY: a search box must not query on the first keystroke, so there is no
|
||||
// leading edge. Each keystroke re-arms the window, and the query runs once the
|
||||
// operator stops typing for FilterDebounceMs. Without this, every character of
|
||||
// a 20-character instance name would be one paged query plus one count
|
||||
// aggregation against central MS SQL.
|
||||
private const int FilterDebounceMs = 500;
|
||||
|
||||
/// <summary>Trailing-edge timer for search-box input. Disposed with the component.</summary>
|
||||
private Timer? _filterDebounceTimer;
|
||||
|
||||
private readonly object _filterLock = new();
|
||||
|
||||
private void OnSearchInput(ChangeEventArgs e)
|
||||
{
|
||||
_searchInput = e.Value?.ToString() ?? string.Empty;
|
||||
|
||||
lock (_filterLock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// Re-arm rather than stack: one pending query per idle gap, not one per
|
||||
// keystroke.
|
||||
_filterDebounceTimer?.Dispose();
|
||||
_filterDebounceTimer = new Timer(
|
||||
_ => OnFilterDebounceElapsed(), null,
|
||||
TimeSpan.FromMilliseconds(FilterDebounceMs), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFilterDebounceElapsed()
|
||||
{
|
||||
lock (_filterLock)
|
||||
{
|
||||
_filterDebounceTimer?.Dispose();
|
||||
_filterDebounceTimer = null;
|
||||
if (_disposed) return;
|
||||
}
|
||||
|
||||
// A changed search term redefines the result set, so the operator's current
|
||||
// page number no longer refers to anything — restart at page 1.
|
||||
_currentPage = 1;
|
||||
_ = DispatchReloadAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the deployment table on the renderer's dispatcher, guarded
|
||||
/// against the component being disposed mid-flight (CentralUI-022):
|
||||
@@ -338,33 +471,78 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetStatusFilterAsync(DeploymentStatus? status)
|
||||
{
|
||||
// Clicking the active tile clears it — a toggle, so the tiles are both the
|
||||
// readout and the control without needing a separate "clear" affordance.
|
||||
_statusFilter = _statusFilter == status ? null : status;
|
||||
_currentPage = 1;
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task ClearFiltersAsync()
|
||||
{
|
||||
_statusFilter = null;
|
||||
_searchInput = string.Empty;
|
||||
_appliedSearch = string.Empty;
|
||||
_currentPage = 1;
|
||||
|
||||
// A cleared box must not be re-queried by an armed keystroke timer.
|
||||
lock (_filterLock)
|
||||
{
|
||||
_filterDebounceTimer?.Dispose();
|
||||
_filterDebounceTimer = null;
|
||||
}
|
||||
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = _records.Count == 0; // Only show loading on first load
|
||||
_loading = _rows.Count == 0; // Only show loading on first load
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
// Build instance lookups first — site scoping (CentralUI-002) filters
|
||||
// deployment records by the site of their instance.
|
||||
var instances = await TemplateEngineRepository.GetAllInstancesAsync();
|
||||
_instanceNames = instances.ToDictionary(i => i.Id, i => i.UniqueName);
|
||||
var instanceSiteIds = instances.ToDictionary(i => i.Id, i => i.SiteId);
|
||||
|
||||
// Site scoping (CentralUI-002) is pushed into the query as the set of
|
||||
// permitted site ids and resolved through the deployment record's
|
||||
// instance, rather than by loading every instance to build an
|
||||
// InstanceId → SiteId map. A system-wide user passes null (no filter);
|
||||
// a scoped user's EMPTY grant is a real filter that matches nothing.
|
||||
var systemWide = await SiteScope.IsSystemWideAsync();
|
||||
var permittedSiteIds = systemWide
|
||||
IReadOnlyCollection<int>? siteScope = systemWide
|
||||
? null
|
||||
: await SiteScope.PermittedSiteIdsAsync();
|
||||
: (await SiteScope.PermittedSiteIdsAsync()).ToArray();
|
||||
|
||||
_records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync())
|
||||
.Where(r => permittedSiteIds == null
|
||||
|| (instanceSiteIds.TryGetValue(r.InstanceId, out var sid)
|
||||
&& permittedSiteIds.Contains(sid)))
|
||||
.OrderByDescending(r => r.DeployedAt)
|
||||
.ToList();
|
||||
_appliedSearch = _searchInput.Trim();
|
||||
var filter = new DeploymentListFilter(
|
||||
Status: _statusFilter,
|
||||
Search: _appliedSearch,
|
||||
SiteIdScope: siteScope);
|
||||
|
||||
_totalPages = Math.Max(1, (int)Math.Ceiling(_records.Count / (double)PageSize));
|
||||
if (_currentPage > _totalPages) _currentPage = 1;
|
||||
UpdatePage();
|
||||
var page = await DeploymentManagerRepository
|
||||
.QueryDeploymentListPageAsync(filter, _currentPage, PageSize);
|
||||
|
||||
// The tile counts deliberately ignore the status filter (see
|
||||
// GetDeploymentStatusCountsAsync) so each tile keeps showing its own
|
||||
// total while it is the one selected.
|
||||
_counts = await DeploymentManagerRepository.GetDeploymentStatusCountsAsync(filter);
|
||||
|
||||
_rows = page.Rows;
|
||||
_totalCount = page.TotalCount;
|
||||
_totalPages = page.PageCount(PageSize);
|
||||
|
||||
// A concurrent retention purge, or a filter that narrowed the set, can
|
||||
// leave the operator past the end. Fall back to the last real page and
|
||||
// re-query rather than rendering a blank table with a live pager.
|
||||
if (_currentPage > _totalPages && _totalPages >= 1)
|
||||
{
|
||||
_currentPage = _totalPages;
|
||||
var reread = await DeploymentManagerRepository
|
||||
.QueryDeploymentListPageAsync(filter, _currentPage, PageSize);
|
||||
_rows = reread.Rows;
|
||||
_totalCount = reread.TotalCount;
|
||||
_totalPages = reread.PageCount(PageSize);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -373,24 +551,13 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void GoToPage(int page)
|
||||
private async Task GoToPageAsync(int page)
|
||||
{
|
||||
if (page < 1 || page > _totalPages) return;
|
||||
if (page < 1 || page > _totalPages || page == _currentPage) return;
|
||||
_currentPage = page;
|
||||
UpdatePage();
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private void UpdatePage()
|
||||
{
|
||||
_pagedRecords = _records
|
||||
.Skip((_currentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string GetInstanceName(int instanceId) =>
|
||||
_instanceNames.GetValueOrDefault(instanceId, $"#{instanceId}");
|
||||
|
||||
private static string GetStatusBadge(DeploymentStatus status) => status switch
|
||||
{
|
||||
DeploymentStatus.Pending => "bg-warning text-dark",
|
||||
@@ -419,5 +586,10 @@
|
||||
_coalesceTimer?.Dispose();
|
||||
_coalesceTimer = null;
|
||||
}
|
||||
lock (_filterLock)
|
||||
{
|
||||
_filterDebounceTimer?.Dispose();
|
||||
_filterDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user