The outbound ApiCall emitter hard-coded RequestSummary/ResponseSummary to null,
so audited API calls carried no inputs/outputs — contrary to the Audit Log
payload-capture spec. Thread the call arguments into the sync ApiCall emitter
and the cached immediate-completion path (CachedSubmit / ApiCallCached /
CachedResolve), and stamp the response body from ExternalCallResult.ResponseJson.
The writer's payload filter still applies the size cap + redaction downstream.
The S&F retry-loop cached rows are unchanged — request data is not threaded
through the store-and-forward buffer (same boundary as SourceScript).
The Audit Log Channel filter becomes a single-select — Kind narrows to the
chosen channel, so multi-channel selection is incoherent. Kind, Status and Site
stay multi-select.
Channel narrows the Kind options to the chosen channel, so filtering by more
than one channel at a time is incoherent. Replace the Channel multi-select
dropdown with a native single-select (matching the Time range control); Kind,
Status and Site stay multi-select. The query filter contract is unchanged —
Channels just carries 0 or 1 value.
Stamp the audit Actor column on outbound rows (calling script identity) and
central-dispatch rows (system identity); the original emission code left it
null on every channel except Inbound API.
Replace the four stacked chip-button groups on the Audit Log filter bar with a
reusable MultiSelectDropdown component, collapsing the bar from four full-width
chip blocks to four inline dropdowns in one wrapped filter row.
Per the Audit Log Actor-column spec, Actor should carry the calling script
identity on outbound rows (ApiCall, DbWrite, NotifySend) and a system identity
on central-dispatch rows (NotifyDeliver). The original emission code hard-coded
Actor=null at all four sites, so only Inbound API rows (API key name) ever
filled it. Stamp the script identity and 'system' respectively.
Replace the four stacked chip-button groups (Channel, Kind, Status, Site) on
the Audit Log filter bar with a reusable MultiSelectDropdown component, so the
bar collapses from four full-width chip blocks to four inline dropdowns sharing
one wrapped filter row. Bootstrap dropdown + checkbox menu (data-bs-auto-close
=outside); no third-party UI libraries.
Implements the five deferred follow-ups from the Audit Log #23 roadmap:
- Real ClusterClient-based site->central audit push (replaces NoOpSiteStreamAuditClient)
- Consolidated the duplicated AuditEvent/SiteCall DTO mappers
- Site Calls UI page + read-side backend + central->site Retry/Discard relay + Health KPI tiles
- Multi-value AuditLogQueryFilter end-to-end (repository, ManagementService, CLI, Central UI)
- Audit results grid column resize/reorder UX
Full solution build clean; full test suite green including Playwright 60/60.
Three Playwright E2E failures, all test-side timing/data bugs (no
feature defects found):
- AuditGridColumnTests.ColumnOrderAndWidths_PersistAcrossReload: read
sessionStorage synchronously right after Mouse.UpAsync, racing the
async OnColumnResized/OnColumnReordered JS->.NET->JS save round-trip.
Now polls (WaitForFunctionAsync) for the storage keys and for the
reorder re-render to settle; also hardens the flaky ReorderDrag test.
- SiteCallsPageTests.FilterNarrowing_ChannelFilterShrinksGrid: the
Target-keyword #sc-search @bind committed via the Query click's own
blur, racing change vs click on the circuit so Search() sometimes
ran with a stale empty filter. Commit the value with an explicit,
fully-awaited DispatchEventAsync('change') and use the retrying
ToHaveCount assertion for the negative row checks.
- SiteCallsPageTests.RetryClickThrough_OnParkedRow: seeded SourceSite
'plant-a' is not a real cluster site (site-a/b/c), so the relay had
no ClusterClient route and only resolved on the 10s inner Ask
timeout - past the 5s toast wait. Seed a live site (site-a) for a
fast NotParked round-trip and give the toast a 15s wait.
Playwright E2E suite: 60 passed, 0 failed, 0 skipped.
Adds drag-to-resize and drag-to-reorder column UX to AuditResultsGrid,
with chosen widths + column order persisted in browser sessionStorage.
- wwwroot/js/audit-grid.js: dependency-free helper — pointer-driven
resize handles, native HTML5 drag-and-drop reorder, and a
sessionStorage save/load wrapper (mirrors treeview-storage.js).
- AuditResultsGrid: renders a resize handle per <th>, makes headers
draggable, applies persisted widths via a --audit-col-width custom
property, and wires reorder into the existing ColumnOrder /
OrderedColumns() mechanism. JS-invokable OnColumnResized /
OnColumnReordered persist + re-render. A stored order naming an
unknown column degrades gracefully (drops unknown keys, appends
missing columns in default order); widths clamp to a 64px minimum.
- AuditResultsGrid.razor.css: subtle scoped styling for the resize
handle affordance and the reorder drop-target highlight.
- App.razor references audit-grid.js alongside the other scripts.
- Tests: 6 new bUnit tests for the load/apply/persist logic and
graceful degradation; a new AuditGridColumnTests Playwright suite
for the drag UX + reload persistence. Audit page bUnit tests set
loose JSInterop mode since the grid now calls into audit-grid.js.
Extract the verbatim-duplicated SiteCallOperationalDto -> SiteCall mapper
into a single public SiteCallDtoMapper static class in
ScadaLink.Communication.Grpc, mirroring AuditEventDtoMapper. Replaces three
identical private copies (SiteStreamGrpcServer.MapSiteCallFromDto,
ClusterClientSiteAuditClient.MapSiteCall, and the test-infra
DirectActorSiteStreamAuditClient.MapSiteCallFromDto), removes the now-stale
doc comment that justified the duplication, and drops the using directives
that became unused. Adds SiteCallDtoMapperTests for field-by-field coverage.
Only the FromDto direction is provided: nothing maps SiteCall back onto the
wire, so a ToDto would be dead code.
The /notifications/report detail modal showed only NotificationSummary
fields. Added a NotificationDetailRequest query (mirrors the existing
outbox query plumbing — Commons contract + NotificationOutboxActor
handler + CommunicationService method) that fetches the full Notification
entity. The modal now fetches on open and renders the message Body
(preformatted plain text, never MarkupString) and resolved recipients
(ResolvedTargets, with a list-name fallback note when not yet resolved).
6 new tests.
Double-clicking a row on /notifications/report opens a Bootstrap modal
showing the notification's full detail (untruncated ID, full LastError,
SourceInstanceId, exact timestamps) — fields the grid truncates or omits.
Parked notifications also get Retry/Discard buttons in the modal footer.
Inline no-JS modal, in-memory NotificationSummary, no extra query.
Both central nodes ran on the NotificationOutboxOptions code defaults
(100 / 10s = 600/min) because the mounted per-node appsettings.Central.json
had no ScadaLink:NotificationOutbox section. Add the section with
DispatchBatchSize 1000 + DispatchInterval 5s — measured ~6,000/min after
restart (sweep duration becomes the binding constraint, which is fine:
the no-overlap guard self-regulates). Dev-cluster tuning only.
Closes the gap found while debugging notification delivery: SmtpConfiguration
has TlsMode + Credentials fields, but no non-SQL path could set them.
- UpdateSmtpConfigCommand carries optional TlsMode + Credentials (nullable,
preserve-if-null in HandleUpdateSmtpConfig — non-breaking for existing
5-arg callers).
- CLI 'notification smtp update' gains optional --tls-mode (validated
None/StartTLS/SSL) and --credentials.
- Central UI SMTP form gains a TLS Mode select (None/StartTLS/SSL) —
previously the form had no TlsMode field at all.
- docs/test_infra/test_infra_smtp.md: replaced the invalid AuthMode:None
example with a working Basic config (TlsMode None, dummy credentials);
corrected the prose (delivery requires Basic/OAuth2, no anonymous mode)
and noted the scadalink-smtp container hostname for in-cluster use.
4 commits, 13 new tests. Full solution green.
The appsettings example used AuthMode 'None', which the delivery code
(MailKitSmtpClientWrapper) rejects — only Basic and OAuth2 are valid.
Switch to a working Basic config with Credentials and TlsMode None, and
document that Server must be the container name scadalink-smtp when the
Notification Service runs inside the docker cluster.
Add a TlsMode read-only row and a None/StartTLS/SSL select to the SMTP
Configuration page edit form. New configs default to None; edits load
and persist the chosen mode through the repository.
Expose the two previously-unreachable SmtpConfiguration fields on the
CLI. Both flags are optional — omitting them sends null so the server
preserves the existing value. --tls-mode is constrained to the canonical
{None, StartTLS, SSL} set via AcceptOnlyFromAmong for fast-fail.
Add two optional nullable fields (TlsMode, Credentials) to the
UpdateSmtpConfigCommand record. The handler applies preserve-if-null
semantics: an update that omits a field leaves the existing value
intact, so existing 5-arg callers remain non-breaking.
ASP.NET Data Protection ciphertext is non-deterministic and bound to the
source key ring, so encrypted secret columns (ExternalSystemDefinitions
.AuthConfiguration, SmtpConfigurations.Credentials, DatabaseConnection
Definitions.ConnectionString) cannot be replayed from a static SQL dump —
the app would fail to decrypt them. dump_seed.py now emits those columns
as NULL; reseed.sh adds a post-seed stage that recreates the values
through the ScadaLink CLI so the EF value converter re-encrypts against
the target cluster's key ring.
M8 — the final milestone — ships the operator CLI surface:
- ManagementService /api/audit/query + /api/audit/export endpoints
(minimal-API, HTTP Basic + LDAP auth, OperationalAudit / AuditExport
role gates reusing M7's AuthorizationPolicies role sets).
- scadalink audit command group:
- audit query: full UI-parity filter set, keyset-cursor paging
(--all follows nextCursor), JSON (default) / table output,
AcceptOnlyFromAmong fast-fail validation on channel/kind/status.
- audit export: streams server CSV / JSONL to --output (parquet → 501
per v1.x deferral), required-flag enforcement.
- audit verify-chain: v1 no-op stub (hash-chain deferred to v1.x);
validates --month, prints the deferral message, exits 0.
- Table output formatter for audit events.
- Pre-existing audit-log config-change command renamed audit-config;
audit-log retained as a deprecation alias (stderr warning).
- CLI README documents the audit group, the rename, permissions.
Review fix: corrected --channel/--kind/--status help + README to the
real enum names (ApiOutbound/DbOutbound/Notification/ApiInbound, etc.)
and added AcceptOnlyFromAmong so a bad value fails fast instead of
silently returning unfiltered results; removed the dead --instance flag
(AuditLogQueryFilter has no instance column).
Shipped: 10 commits, ~62 net new tests.
=== Audit Log #23 — implementation complete (M1-M8) ===
Full solution: 24 test projects, 2,993 tests, 0 failures, 0 skipped.
dotnet build ScadaLink.slnx clean. infra/* never touched on any of the
8 milestone branches. alog.md changed exactly once (M1 vocabulary
reconciliation, committed before the dependent code merge per the
ordering invariant).