Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor
T
Joseph Doherty 727fa48cba feat(localdb): move site_events to application-minted GUID ids
Tasks 2 + 5a-writer + 5b of the LocalDb Phase 1 plan, landed together because
they are one indivisible change: the schema, the writer that fills it, and every
consumer that assumed the old id semantics.

WHY the id changes. Site pairs will replicate site_events with last-writer-wins on
the primary key. With INTEGER PRIMARY KEY AUTOINCREMENT both nodes independently
mint 1, 2, 3... for unrelated events, so sync would treat them as the same row and
silently overwrite. A GUID makes the event log a pure union across the pair.

Task 2 - schema extracted so the Host's AddZbLocalDb onReady can create the tables
before RegisterReplicated installs capture triggers (pre-registration rows are
never captured):
  - new OperationTrackingSchema.Apply / SiteEventLogSchema.Apply, plain
    Microsoft.Data.Sqlite, no LocalDb dependency
  - both stores delegate their InitializeSchema to them (idempotent, so a
    directly-constructed store still works)
  - OperationTracking is unchanged - it already had a TEXT PK and replicates as-is

Task 5a - SiteEventLogger mints Guid.NewGuid("N") per event and inserts it.

Task 5b - three consumers assumed a monotonic integer id. All three move to
timestamp ordering; leaving any one behind would be a live bug:
  - EventLogQueryService: "id > $afterId" would return an ARBITRARY subset of a
    GUID-keyed table and SILENTLY DROP ROWS from page-through. Now a composite
    (timestamp, id) keyset cursor with an opaque string token; timestamps are not
    unique, so id is the tie-break that guarantees exactly-once paging.
  - EventLogPurgeService: "ORDER BY id ASC LIMIT 1000" would delete a RANDOM batch
    instead of the oldest. Now orders by timestamp.
  - EventLogEntry.Id and both ContinuationTokens: long -> string.

WIRE COMPATIBILITY. Those DTOs cross the site<->central Akka boundary
(SiteCommunicationActor -> CommunicationService -> ManagementActor / CentralUI).
No rolling-upgrade shim is needed because both sides ship in the same deployable
and the rig redeploys as a unit. Checked: no Akka serializer binding pins these
types by name. A stale numeric token degrades to "start from the beginning"
(a visible repeat) rather than throwing or losing rows.

Tests: the two that encoded the old semantics were rewritten to guard the new
invariant rather than deleted - uniqueness instead of monotonicity, and
oldest-purged-first keyed on timestamp. That second test also exposed a latent
weakness: the bulk seed stamped every row with the same UtcNow, so "oldest" was
never actually well-defined; rows now get distinct increasing timestamps, kept
inside the retention window so the retention purge does not eat them first.

Verified:
  dotnet build ZB.MOM.WW.ScadaBridge.slnx -> 0 Error(s), 0 Warning(s)
  SiteEventLogging.Tests -> 70 passed (12 new schema tests, red-first)
  CentralUI.Tests        -> 925 passed
  Commons.Tests          -> 684 passed
  SiteRuntime.Tests      -> 529 passed, 1 pre-existing flaky failure
                            (InstanceActorChildAttributeRaceTests - passes 3/3 in
                            isolation with and without this change)

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-19 03:15:17 -04:00

316 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/monitoring/event-logs"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery
@using ZB.MOM.WW.ScadaBridge.Communication
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject CommunicationService CommunicationService
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Event Logs</h4>
<button class="btn btn-outline-secondary btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#event-logs-filters"
aria-expanded="true"
aria-controls="event-logs-filters">
Filter options (@ActiveFilterCount active)
</button>
</div>
<ToastNotification @ref="_toast" />
<div class="collapse show" id="event-logs-filters">
<div class="row mb-3 g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small" for="filter-site">Site</label>
<select id="filter-site" class="form-select form-select-sm" aria-label="Site" @bind="_selectedSiteId">
<option value="">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-event-type">Event Type</label>
<input id="filter-event-type"
type="text"
class="form-control form-control-sm"
aria-label="Event type"
@bind="_filterEventType"
placeholder="e.g. ScriptError" />
</div>
<div class="col-md-1">
<label class="form-label small" for="filter-severity">Severity</label>
<select id="filter-severity"
class="form-select form-select-sm"
aria-label="Severity"
@bind="_filterSeverity">
<option value="">All</option>
<option>Info</option>
<option>Warning</option>
<option>Error</option>
</select>
</div>
<div class="col-md-4">
<DateTimeRangeFilter From="_filterFrom" FromChanged="@(v => _filterFrom = v)"
To="_filterTo" ToChanged="@(v => _filterTo = v)"
IdPrefix="filter" />
</div>
<div class="col-md-1">
<label class="form-label small" for="filter-keyword">Message contains</label>
<input id="filter-keyword"
type="text"
class="form-control form-control-sm"
aria-label="Message contains"
@bind="_filterKeyword" />
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-instance">Instance</label>
<input id="filter-instance"
type="text"
class="form-control form-control-sm"
aria-label="Instance name"
@bind="_filterInstanceName"
placeholder="Instance name" />
</div>
<div class="col-md-12 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
</div>
</div>
</div>
@if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
@if (_entries != null)
{
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th style="width: 1%;"></th>
<th>Timestamp</th>
<th>Type</th>
<th>Severity</th>
<th>Instance</th>
<th>Source</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@if (_entries.Count == 0)
{
<tr><td colspan="7" class="text-muted text-center">No events found.</td></tr>
}
@for (int i = 0; i < _entries.Count; i++)
{
var idx = i;
var entry = _entries[idx];
var rowClass = entry.Severity == "Error" ? "table-danger"
: entry.Severity == "Warning" ? "table-warning"
: "";
var expanded = _expandedRows.Contains(idx);
<tr class="@rowClass">
<td>
<button class="btn btn-link btn-sm p-0"
@onclick="() => ToggleRow(idx)"
aria-label="@(expanded ? "Hide full message" : "View full message")">
@(expanded ? "Hide" : "View")
</button>
</td>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small">@entry.EventType</td>
<td>
<span class="badge @GetSeverityBadge(entry.Severity)" aria-label="Severity: @entry.Severity">
@SeverityGlyph(entry.Severity) @entry.Severity
</span>
</td>
<td class="small">@(entry.InstanceId ?? "—")</td>
<td class="small">@entry.Source</td>
<td class="small text-truncate" style="max-width: 380px;">@entry.Message</td>
</tr>
@if (expanded)
{
<tr class="@rowClass">
<td colspan="7">
<pre class="small mb-0">@entry.Message</pre>
</td>
</tr>
}
}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Showing @_entries.Count entries</span>
<div>
@if (_hasMore)
{
<button class="btn btn-outline-primary btn-sm" @onclick="LoadMore" disabled="@_searching">Load more</button>
}
else if (_entries.Count > 0)
{
<span class="text-muted small">End of results</span>
}
</div>
</div>
}
</div>
@code {
private List<Site> _sites = new();
private string _selectedSiteId = string.Empty;
private string? _filterEventType;
private string _filterSeverity = string.Empty;
private DateTime? _filterFrom;
private DateTime? _filterTo;
private string? _filterKeyword;
private string? _filterInstanceName;
private List<EventLogEntry>? _entries;
private bool _hasMore;
// Opaque keyset cursor from the previous response — passed back verbatim, never
// parsed. It became a string in LocalDb Phase 1 when site_events ids turned into
// GUIDs and the cursor had to carry the timestamp too.
private string? _continuationToken;
private bool _searching;
private string? _errorMessage;
private ToastNotification _toast = default!;
private readonly HashSet<int> _expandedRows = new();
private int ActiveFilterCount
{
get
{
var n = 0;
if (!string.IsNullOrEmpty(_selectedSiteId)) n++;
if (!string.IsNullOrWhiteSpace(_filterEventType)) n++;
if (!string.IsNullOrEmpty(_filterSeverity)) n++;
if (_filterFrom.HasValue) n++;
if (_filterTo.HasValue) n++;
if (!string.IsNullOrWhiteSpace(_filterKeyword)) n++;
if (!string.IsNullOrWhiteSpace(_filterInstanceName)) n++;
return n;
}
}
protected override async Task OnInitializedAsync()
{
// Site scoping (CentralUI-002): a scoped Deployment user may only query
// event logs for the sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
}
// _sites is already filtered, so membership IS the scope check.
private bool SelectedSiteIsPermitted =>
!string.IsNullOrEmpty(_selectedSiteId)
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
private async Task Search()
{
_entries = new();
_continuationToken = null;
_expandedRows.Clear();
await FetchPage();
}
private async Task LoadMore() => await FetchPage();
private void ToggleRow(int idx)
{
if (!_expandedRows.Add(idx))
{
_expandedRows.Remove(idx);
}
}
private async Task FetchPage()
{
_searching = true;
_errorMessage = null;
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
// filtered, but the selection must not be trusted on its own.
if (!SelectedSiteIsPermitted)
{
_errorMessage = "You are not permitted to view event logs for that site.";
_searching = false;
return;
}
try
{
var request = new EventLogQueryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
SiteId: _selectedSiteId,
// CentralUI-027: <input type="datetime-local"> binds with DateTimeKind.Unspecified
// — the value is the operator's browser-local wall-clock. Tag it Local and
// convert to UTC; the prior code labelled the local value as UTC, silently
// shifting the query window by the operator's UTC offset.
From: LocalInputToUtc(_filterFrom),
To: LocalInputToUtc(_filterTo),
EventType: string.IsNullOrWhiteSpace(_filterEventType) ? null : _filterEventType.Trim(),
Severity: string.IsNullOrWhiteSpace(_filterSeverity) ? null : _filterSeverity,
InstanceId: string.IsNullOrWhiteSpace(_filterInstanceName) ? null : _filterInstanceName.Trim(),
KeywordFilter: string.IsNullOrWhiteSpace(_filterKeyword) ? null : _filterKeyword.Trim(),
ContinuationToken: _continuationToken,
PageSize: 50,
Timestamp: DateTimeOffset.UtcNow);
var response = await CommunicationService.QueryEventLogsAsync(_selectedSiteId, request);
if (response.Success)
{
_entries ??= new();
_entries.AddRange(response.Entries);
_hasMore = response.HasMore;
_continuationToken = response.ContinuationToken;
}
else
{
_errorMessage = response.ErrorMessage ?? "Query failed.";
}
}
catch (Exception ex)
{
_errorMessage = $"Query failed: {ex.Message}";
}
_searching = false;
}
/// <summary>
/// CentralUI-027: convert a value bound from <c>&lt;input type="datetime-local"&gt;</c>
/// (DateTimeKind.Unspecified, operator's browser-local wall-clock) into UTC. Must tag
/// the value Local before <see cref="DateTime.ToUniversalTime"/> can do anything.
/// </summary>
private static DateTimeOffset? LocalInputToUtc(DateTime? value) =>
value.HasValue
? new DateTimeOffset(
DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime(),
TimeSpan.Zero)
: (DateTimeOffset?)null;
private static string GetSeverityBadge(string severity) => severity switch
{
"Error" => "bg-danger",
"Warning" => "bg-warning text-dark",
"Info" => "bg-info text-dark",
_ => "bg-secondary"
};
private static string SeverityGlyph(string severity) => severity switch
{
"Error" => "⛔",
"Warning" => "⚠",
"Info" => "",
_ => "•"
};
}