feat(ui): Audit KPI tiles on Health dashboard (#23 M7)
Adds three KPI tiles to the central Health dashboard for the Audit channel: volume (rows in the last hour), error rate (Failed/Parked/Discarded over total), and backlog (sum of SiteAuditBacklog.PendingCount across all sites). Repo + service: - IAuditLogRepository.GetKpiSnapshotAsync(window, nowUtc) — single aggregate SELECT over the trailing window returning total + error counts; nowUtc is optional for production callers and pinned by integration tests against the shared MSSQL fixture so the global counts are deterministic. - AuditLogQueryService.GetKpiSnapshotAsync() — composes the repo aggregate with a sum of SiteAuditBacklog.PendingCount read from ICentralHealthAggregator. - AuditLogKpiSnapshot record in Commons/Types/. UI: - New AuditKpiTiles Blazor component (Components/Health/) — three Bootstrap card-tiles, click navigates to /audit/log with the matching pre-filter. - Health.razor wires the tiles in alongside the existing Notification Outbox KPIs; LoadAuditKpis() runs on every 10s refresh tick and degrades to em dashes + inline error if the query fails. - AuditLogPage extended to parse ?status= so the error-rate tile drill-in (?status=Failed) auto-loads the grid. Tests: - AuditLogRepositoryTests: GetKpiSnapshotAsync mixed-status + empty-window cases against the MSSQL migration fixture. - AuditLogQueryServiceTests: forwarding + backlog composition; sites with null SiteAuditBacklog contribute zero. - AuditKpiTilesTests: 9 bUnit tests covering tile render, error-rate maths with safe zero-events handling, em-dash unavailable path, click-through navigation, and warning/danger border thresholds. - HealthPageTests: new Renders_AuditKpiTiles_WithValues plus IAuditLogQueryService stub registration in the constructor so existing outbox tests still pass. - AuditLogPageScaffoldTests: ?status=Failed auto-load + unknown status drop.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
using Bunit;
|
||||
using Bunit.TestDoubles;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.CentralUI.Components.Health;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Components.Health;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="AuditKpiTiles"/> (#23 M7 Bundle E / M7-T13). The
|
||||
/// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog —
|
||||
/// from a single <see cref="AuditLogKpiSnapshot"/>. The tests pin:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
|
||||
/// <item>Error-rate maths: <c>ErrorEventsLastHour / TotalEventsLastHour</c> with
|
||||
/// safe zero-events handling (no DivideByZero, displays "0.0%").</item>
|
||||
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
|
||||
/// <item>Tile clicks navigate to the correct pre-filtered Audit Log URL.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class AuditKpiTilesTests : BunitContext
|
||||
{
|
||||
private static AuditLogKpiSnapshot MakeSnapshot(long total, long errors, long backlog) =>
|
||||
new(total, errors, backlog, new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
[Fact]
|
||||
public void Renders_ThreeTiles_FromSnapshot()
|
||||
{
|
||||
var cut = Render<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 120, errors: 3, backlog: 7))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
// Three stable data-test selectors — these are the contract for both
|
||||
// tests and any future Playwright sweep.
|
||||
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
|
||||
|
||||
// Tile values render the snapshot's counters.
|
||||
Assert.Contains("120", cut.Markup); // volume
|
||||
Assert.Contains("7", cut.Markup); // backlog
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorRate_Computed_From_Total_AndErrors()
|
||||
{
|
||||
// 5 errors out of 100 → 5.0%.
|
||||
var cut = Render<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
Assert.Contains("5.0%", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroEvents_DoesNotDivideByZero_RendersZeroPercent()
|
||||
{
|
||||
// Total = 0 → naïve division would throw or yield NaN. The tile must
|
||||
// render "0.0%" instead (zero events means zero errors too — a real
|
||||
// signal, not an unavailability marker).
|
||||
var cut = Render<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 0, errors: 0, backlog: 0))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
Assert.Contains("0.0%", cut.Markup);
|
||||
// And the volume tile shows "0", not an em dash — the snapshot itself
|
||||
// is available; the system was just quiet for the hour.
|
||||
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
|
||||
{
|
||||
var cut = Render<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, (AuditLogKpiSnapshot?)null)
|
||||
.Add(c => c.IsAvailable, false)
|
||||
.Add(c => c.ErrorMessage, "DB connection refused"));
|
||||
|
||||
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
|
||||
Assert.Contains("—", cut.Markup);
|
||||
// Inline error message renders below.
|
||||
Assert.Contains("Audit KPIs unavailable", cut.Markup);
|
||||
Assert.Contains("DB connection refused", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorRateTile_Click_NavigatesToAuditLog_WithFailedStatusFilter()
|
||||
{
|
||||
var cut = Render<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
// bUnit's BunitNavigationManager records the last URI a Navigation.NavigateTo call hit.
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
|
||||
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
|
||||
tile.Click();
|
||||
|
||||
// Spec: error-rate tile drills into ?status=Failed.
|
||||
Assert.Contains("/audit/log?status=Failed", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VolumeTile_Click_NavigatesToUnfilteredAuditLog()
|
||||
{
|
||||
var cut = Render<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
var tile = cut.Find("[data-test=\"audit-kpi-volume\"]");
|
||||
tile.Click();
|
||||
|
||||
// Unfiltered /audit/log — no query string.
|
||||
Assert.EndsWith("/audit/log", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BacklogTile_Click_NavigatesToAuditLog()
|
||||
{
|
||||
var cut = Render<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 0, backlog: 12))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
var tile = cut.Find("[data-test=\"audit-kpi-backlog\"]");
|
||||
tile.Click();
|
||||
|
||||
Assert.EndsWith("/audit/log", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonzeroErrorRate_GetsWarningBorder_NotDangerBelowTenPercent()
|
||||
{
|
||||
// 5% is < 10% → warning border, not danger.
|
||||
var cut = Render<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
|
||||
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
|
||||
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighErrorRate_GetsDangerBorder()
|
||||
{
|
||||
// 25% is > 10% → danger border.
|
||||
var cut = Render<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 25, backlog: 0))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
|
||||
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,44 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithStatusParam_AppliesStatusFilter()
|
||||
{
|
||||
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills
|
||||
// in with ?status=Failed. The page parses the enum (case-insensitive),
|
||||
// builds an AuditLogQueryFilter with Status set, and auto-loads.
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.Status == AuditStatus.Failed),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Admin");
|
||||
|
||||
// An unparseable status value leaves Status null. With no other filter
|
||||
// params present the page renders but does NOT call the query service
|
||||
// (matching the existing "no params" contract).
|
||||
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
|
||||
_queryService.DidNotReceive().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
|
||||
{
|
||||
|
||||
@@ -6,9 +6,11 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ScadaLink.CentralUI.Services;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Notification;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Communication;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
using HealthPage = ScadaLink.CentralUI.Components.Pages.Monitoring.Health;
|
||||
@@ -55,6 +57,16 @@ public class HealthPageTests : BunitContext
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
// Audit Log (#23) M7 Bundle E — the Health page now also fetches the
|
||||
// Audit KPI snapshot. Stub it with an empty point-in-time reading so
|
||||
// the existing assertions (Notification Outbox tiles, Online/Offline
|
||||
// counts) keep passing; tests that target the Audit tiles set their
|
||||
// own substitute.
|
||||
var auditService = Substitute.For<IAuditLogQueryService>();
|
||||
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
|
||||
Services.AddSingleton(auditService);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
@@ -92,6 +104,35 @@ public class HealthPageTests : BunitContext
|
||||
Assert.Contains("View details", link.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_AuditKpiTiles_WithValues()
|
||||
{
|
||||
// Override the default empty snapshot — this test wants concrete values
|
||||
// to land in the three Audit tiles.
|
||||
var auditService = Substitute.For<IAuditLogQueryService>();
|
||||
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(
|
||||
TotalEventsLastHour: 250,
|
||||
ErrorEventsLastHour: 5,
|
||||
BacklogTotal: 17,
|
||||
AsOfUtc: DateTime.UtcNow)));
|
||||
Services.AddSingleton(auditService);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// The three audit tiles render at the documented data-test selectors.
|
||||
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
|
||||
// Volume shows the formatted thousand-separator value.
|
||||
Assert.Contains("250", cut.Markup);
|
||||
// Backlog renders 17.
|
||||
Assert.Contains("17", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboxKpiFailure_ShowsGracefulFallback()
|
||||
{
|
||||
|
||||
@@ -2,8 +2,11 @@ using NSubstitute;
|
||||
using ScadaLink.CentralUI.Services;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Health;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Services;
|
||||
|
||||
@@ -15,6 +18,13 @@ namespace ScadaLink.CentralUI.Tests.Services;
|
||||
/// </summary>
|
||||
public class AuditLogQueryServiceTests
|
||||
{
|
||||
private static ICentralHealthAggregator EmptyAggregator()
|
||||
{
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(new Dictionary<string, SiteHealthState>());
|
||||
return agg;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
||||
{
|
||||
@@ -28,7 +38,7 @@ public class AuditLogQueryServiceTests
|
||||
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(expected));
|
||||
|
||||
var sut = new AuditLogQueryService(repo);
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
var result = await sut.QueryAsync(filter, paging);
|
||||
|
||||
@@ -44,7 +54,7 @@ public class AuditLogQueryServiceTests
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogQueryService(repo);
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
await sut.QueryAsync(new AuditLogQueryFilter(), paging: null);
|
||||
|
||||
@@ -54,4 +64,103 @@ public class AuditLogQueryServiceTests
|
||||
Assert.Null(observed.AfterOccurredAtUtc);
|
||||
Assert.Null(observed.AfterEventId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var anchor = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var repoSnapshot = new AuditLogKpiSnapshot(
|
||||
TotalEventsLastHour: 42,
|
||||
ErrorEventsLastHour: 7,
|
||||
BacklogTotal: 0, // repo leaves this at zero
|
||||
AsOfUtc: anchor);
|
||||
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(repoSnapshot));
|
||||
|
||||
// Two sites: plant-a with PendingCount=5, plant-b with PendingCount=11.
|
||||
// Sum = 16 → backlog tile shows 16.
|
||||
var sites = new Dictionary<string, SiteHealthState>
|
||||
{
|
||||
["plant-a"] = StateWithBacklog("plant-a", pending: 5),
|
||||
["plant-b"] = StateWithBacklog("plant-b", pending: 11),
|
||||
};
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(sites);
|
||||
|
||||
var sut = new AuditLogQueryService(repo, agg);
|
||||
|
||||
var snapshot = await sut.GetKpiSnapshotAsync();
|
||||
|
||||
Assert.Equal(42, snapshot.TotalEventsLastHour);
|
||||
Assert.Equal(7, snapshot.ErrorEventsLastHour);
|
||||
Assert.Equal(16, snapshot.BacklogTotal);
|
||||
Assert.Equal(anchor, snapshot.AsOfUtc);
|
||||
|
||||
// The service requests a 1-hour trailing window and lets the repo
|
||||
// anchor nowUtc to its own clock — we leave the second parameter null.
|
||||
await repo.Received(1).GetKpiSnapshotAsync(
|
||||
TimeSpan.FromHours(1),
|
||||
Arg.Is<DateTime?>(v => v == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
|
||||
|
||||
// plant-a has no LatestReport at all; plant-b has a report but null SiteAuditBacklog.
|
||||
var sites = new Dictionary<string, SiteHealthState>
|
||||
{
|
||||
["plant-a"] = new() { SiteId = "plant-a", LatestReport = null, IsOnline = true },
|
||||
["plant-b"] = StateWithBacklog("plant-b", pending: null),
|
||||
["plant-c"] = StateWithBacklog("plant-c", pending: 4),
|
||||
};
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(sites);
|
||||
|
||||
var sut = new AuditLogQueryService(repo, agg);
|
||||
|
||||
var snapshot = await sut.GetKpiSnapshotAsync();
|
||||
|
||||
// Only plant-c contributes; plant-a (no report) and plant-b (null backlog) yield zero.
|
||||
Assert.Equal(4, snapshot.BacklogTotal);
|
||||
}
|
||||
|
||||
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
|
||||
{
|
||||
SiteAuditBacklogSnapshot? backlog = pending.HasValue
|
||||
? new SiteAuditBacklogSnapshot(pending.Value, OldestPendingUtc: null, OnDiskBytes: 0)
|
||||
: null;
|
||||
var report = new SiteHealthReport(
|
||||
SiteId: siteId,
|
||||
SequenceNumber: 1,
|
||||
ReportTimestamp: DateTimeOffset.UtcNow,
|
||||
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
|
||||
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
||||
ScriptErrorCount: 0,
|
||||
AlarmEvaluationErrorCount: 0,
|
||||
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
||||
DeadLetterCount: 0,
|
||||
DeployedInstanceCount: 0,
|
||||
EnabledInstanceCount: 0,
|
||||
DisabledInstanceCount: 0,
|
||||
SiteAuditBacklog: backlog);
|
||||
return new SiteHealthState
|
||||
{
|
||||
SiteId = siteId,
|
||||
LatestReport = report,
|
||||
LastReportReceivedAt = DateTimeOffset.UtcNow,
|
||||
LastHeartbeatAt = DateTimeOffset.UtcNow,
|
||||
LastSequenceNumber = 1,
|
||||
IsOnline = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user