Compare commits
4 Commits
feature/au
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77922abb33 | ||
|
|
5f544bfe1e | ||
|
|
aaa6df24cf | ||
|
|
ae7329034f |
@@ -6,20 +6,23 @@
|
||||
|
||||
<div class="card mb-3" data-test="audit-filter-bar">
|
||||
<div class="card-body py-2">
|
||||
@* All filters sit in one wrapped row. The four multi-value dimensions
|
||||
(Channel / Kind / Status / Site) use compact MultiSelectDropdown
|
||||
controls so the bar stays a row or two tall instead of four stacked
|
||||
blocks of chip buttons. *@
|
||||
@* All filters sit in one wrapped row. Kind / Status / Site use compact
|
||||
MultiSelectDropdown controls; Channel is a single-select because the
|
||||
Kind options narrow to the chosen channel — so the bar stays a row or
|
||||
two tall instead of four stacked blocks of chip buttons. *@
|
||||
<div class="row g-2 align-items-end">
|
||||
@* Single-select: one channel at a time, so the Kind options below
|
||||
narrow cleanly to that channel. "All channels" clears it. *@
|
||||
<div class="col-auto" data-test="filter-channel">
|
||||
<label class="form-label small mb-1">Channel</label>
|
||||
<div>
|
||||
<MultiSelectDropdown TValue="AuditChannel"
|
||||
Items="_channels"
|
||||
Selected="_model.Channels"
|
||||
SelectionChanged="OnChannelsChanged"
|
||||
DataTest="filter-channel-ms" />
|
||||
</div>
|
||||
<label class="form-label small mb-1" for="audit-channel">Channel</label>
|
||||
<select id="audit-channel" data-test="filter-channel-select"
|
||||
class="form-select form-select-sm" @bind="SelectedChannel">
|
||||
<option value="">All channels</option>
|
||||
@foreach (var channel in _channels)
|
||||
{
|
||||
<option value="@channel">@channel</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@
|
||||
|
||||
@@ -8,13 +8,14 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
||||
/// <summary>
|
||||
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
|
||||
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls
|
||||
/// — Channel / Kind / Status / Site as compact
|
||||
/// — Channel as a single-select (one channel at a time, so the Kind options
|
||||
/// narrow to it cleanly); Kind / Status / Site as compact
|
||||
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
|
||||
/// controls, plus the time range, free-text searches and the Errors-only
|
||||
/// controls; plus the time range, free-text searches and the Errors-only
|
||||
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
|
||||
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The four
|
||||
/// multi-value dimensions map straight through to the filter's list fields;
|
||||
/// see <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
|
||||
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
|
||||
/// dimensions map through to the filter's list fields; see
|
||||
/// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
|
||||
/// </summary>
|
||||
public partial class AuditFilterBar
|
||||
{
|
||||
@@ -82,8 +83,29 @@ public partial class AuditFilterBar
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs after a Channel selection changes. Drops any Kind selections that fell
|
||||
/// outside the new visible set — without this, removing a channel could leave
|
||||
/// Single-select Channel binding for the filter bar. The Audit Log filters one
|
||||
/// channel at a time so the Kind options narrow cleanly to it; the model still
|
||||
/// stores the selection as a set (0 or 1 entry) so <see cref="AuditQueryModel.ToFilter"/>
|
||||
/// and <see cref="AuditQueryModel.VisibleKinds"/> are unchanged. <c>null</c> = all channels.
|
||||
/// </summary>
|
||||
private AuditChannel? SelectedChannel
|
||||
{
|
||||
get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
|
||||
set
|
||||
{
|
||||
_model.Channels.Clear();
|
||||
if (value is { } channel)
|
||||
{
|
||||
_model.Channels.Add(channel);
|
||||
}
|
||||
|
||||
OnChannelsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs after the Channel selection changes. Drops any Kind selections that fell
|
||||
/// outside the new visible set — without this, changing the channel could leave
|
||||
/// stale Kind selections that no longer match any visible option.
|
||||
/// </summary>
|
||||
private void OnChannelsChanged()
|
||||
|
||||
@@ -30,6 +30,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
private const int FallbackMaxRetries = 10;
|
||||
private static readonly TimeSpan FallbackRetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Audit <c>Actor</c> stamped on central-dispatch (<c>NotifyDeliver</c>) rows.
|
||||
/// The Actor-column spec assigns central-originated audit rows a system
|
||||
/// identity — there is no per-call authenticated user at dispatch time.
|
||||
/// </summary>
|
||||
private const string SystemActor = "system";
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly NotificationOutboxOptions _options;
|
||||
private readonly ICentralAuditWriter _auditWriter;
|
||||
@@ -500,9 +507,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
Channel = AuditChannel.Notification,
|
||||
Kind = AuditKind.NotifyDeliver,
|
||||
CorrelationId = correlationId,
|
||||
// Central dispatch — no authenticated actor (the originating
|
||||
// script's identity is captured on the upstream NotifySend row).
|
||||
Actor = null,
|
||||
// Central dispatch — a system identity per the Actor-column spec;
|
||||
// there is no per-call authenticated user here. The originating
|
||||
// script is still captured on SourceScript (and on the upstream
|
||||
// NotifySend row).
|
||||
Actor = SystemActor,
|
||||
SourceSiteId = notification.SourceSiteId,
|
||||
SourceInstanceId = notification.SourceInstanceId,
|
||||
SourceScript = notification.SourceScript,
|
||||
|
||||
@@ -430,7 +430,10 @@ internal sealed class AuditingDbCommand : DbCommand
|
||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||
SourceInstanceId = _instanceName,
|
||||
SourceScript = _sourceScript,
|
||||
Actor = null,
|
||||
// Outbound channel: per the Audit Log Actor-column spec the actor is
|
||||
// the calling script. Null when no single script owns the call
|
||||
// (e.g. a shared script running inline).
|
||||
Actor = _sourceScript,
|
||||
Target = target,
|
||||
Status = status,
|
||||
HttpStatus = null,
|
||||
|
||||
@@ -875,7 +875,10 @@ public class ScriptRuntimeContext
|
||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||
SourceInstanceId = _instanceName,
|
||||
SourceScript = _sourceScript,
|
||||
Actor = null,
|
||||
// Outbound channel: per the Audit Log Actor-column spec the actor
|
||||
// is the calling script. Null when no single script owns the call
|
||||
// (e.g. a shared script running inline).
|
||||
Actor = _sourceScript,
|
||||
Target = $"{systemName}.{methodName}",
|
||||
Status = status,
|
||||
HttpStatus = httpStatus,
|
||||
@@ -1355,7 +1358,10 @@ public class ScriptRuntimeContext
|
||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||
SourceInstanceId = _instanceName,
|
||||
SourceScript = _sourceScript,
|
||||
Actor = null,
|
||||
// Outbound channel: per the Audit Log Actor-column spec the
|
||||
// actor is the calling script. Null when no single script
|
||||
// owns the call (e.g. a shared script running inline).
|
||||
Actor = _sourceScript,
|
||||
Target = _listName,
|
||||
Status = AuditStatus.Submitted,
|
||||
HttpStatus = null,
|
||||
|
||||
@@ -91,9 +91,8 @@ public class AuditLogPageTests
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Pre-Apply, both rows are absent because the grid stays empty until
|
||||
// the user filters. Open the Channel dropdown, tick ApiOutbound, Apply.
|
||||
await page.Locator("[data-test='filter-channel-ms-toggle']").ClickAsync();
|
||||
await page.Locator("[data-test='filter-channel-ms-opt-ApiOutbound']").ClickAsync();
|
||||
// the user filters. Pick the ApiOutbound channel, then Apply.
|
||||
await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
|
||||
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
|
||||
///
|
||||
/// The bar carries the 10 spec filter elements plus the Errors-only toggle. The
|
||||
/// Channel / Kind / Status / Site dimensions are rendered as
|
||||
/// The bar carries the 10 spec filter elements plus the Errors-only toggle.
|
||||
/// Channel is a single-select <c><select data-test="filter-channel-select"></c>;
|
||||
/// Kind / Status / Site are
|
||||
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
|
||||
/// controls; each option is a checkbox tagged
|
||||
/// controls whose options are checkboxes tagged
|
||||
/// <c>data-test="filter-<dim>-ms-opt-<value>"</c>. Tests pin:
|
||||
/// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with
|
||||
/// the selected values; (3) the Channel→Kind narrowing map drives Kind option
|
||||
@@ -75,8 +76,8 @@ public class AuditFilterBarTests : BunitContext
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
// Drive UI: tick a Channel option, type in the Target search box, click Apply.
|
||||
cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true);
|
||||
// Drive UI: pick a Channel, type in the Target search box, click Apply.
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
|
||||
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
@@ -86,23 +87,25 @@ public class AuditFilterBarTests : BunitContext
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_WithMultipleChannelChips_PassesAllSelectedChannels()
|
||||
public void ChangingChannel_ReplacesTheSelection_SingleSelect()
|
||||
{
|
||||
// Task 9: ToFilter no longer collapses the chip multi-select — every
|
||||
// selected channel chip reaches the filter's Channels list.
|
||||
// Channel is single-select: picking a second channel replaces the first
|
||||
// rather than adding to it (the page filters one channel at a time).
|
||||
AuditLogQueryFilter? captured = null;
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true);
|
||||
cut.Find("[data-test=\"filter-channel-ms-opt-Notification\"]").Change(true);
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.NotNull(captured!.Channels);
|
||||
Assert.Equal(2, captured.Channels!.Count);
|
||||
Assert.Contains(AuditChannel.ApiOutbound, captured.Channels);
|
||||
Assert.Contains(AuditChannel.Notification, captured.Channels);
|
||||
Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);
|
||||
|
||||
// Selecting "All channels" clears the channel filter entirely.
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
Assert.Null(captured!.Channels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -116,8 +119,8 @@ public class AuditFilterBarTests : BunitContext
|
||||
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
|
||||
}
|
||||
|
||||
// Select only ApiOutbound; Kind options outside the channel-kind map drop out.
|
||||
cut.Find("[data-test=\"filter-channel-ms-opt-ApiOutbound\"]").Change(true);
|
||||
// Select ApiOutbound; Kind options outside the channel-kind map drop out.
|
||||
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
|
||||
|
||||
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
|
||||
foreach (var kind in apiKinds)
|
||||
|
||||
@@ -155,8 +155,8 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
|
||||
Assert.Equal("site-alpha", evt.SourceSiteId);
|
||||
Assert.Equal("instance-42", evt.SourceInstanceId);
|
||||
Assert.Equal("AlarmScript", evt.SourceScript);
|
||||
// Central dispatch: actor is null (no authenticated end-user).
|
||||
Assert.Null(evt.Actor);
|
||||
// Central dispatch: Actor is the system identity (no per-call user).
|
||||
Assert.Equal("system", evt.Actor);
|
||||
// Successful attempt: no error message.
|
||||
Assert.Null(evt.ErrorMessage);
|
||||
});
|
||||
|
||||
@@ -266,7 +266,8 @@ public class DatabaseSyncEmissionTests
|
||||
Assert.Equal(SiteId, evt.SourceSiteId);
|
||||
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||
Assert.Equal(SourceScript, evt.SourceScript);
|
||||
Assert.Null(evt.Actor);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
Assert.Null(evt.CorrelationId);
|
||||
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||
}
|
||||
|
||||
@@ -186,7 +186,8 @@ public class ExternalSystemCallAuditEmissionTests
|
||||
Assert.Equal(SiteId, evt.SourceSiteId);
|
||||
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||
Assert.Equal(SourceScript, evt.SourceScript);
|
||||
Assert.Null(evt.Actor);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
Assert.Null(evt.CorrelationId);
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Null(evt.HttpStatus);
|
||||
Assert.Null(evt.ErrorMessage);
|
||||
Assert.Null(evt.ErrorDetail);
|
||||
Assert.Null(evt.Actor);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -199,7 +200,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
|
||||
Assert.Equal(SiteId, evt.SourceSiteId);
|
||||
Assert.Equal(InstanceName, evt.SourceInstanceId);
|
||||
Assert.Equal(SourceScript, evt.SourceScript);
|
||||
Assert.Null(evt.Actor);
|
||||
// Outbound channel: Actor carries the calling script identity.
|
||||
Assert.Equal(SourceScript, evt.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user