Files
ScadaBridge/src/ScadaLink.SiteEventLogging/EventLogQueryService.cs
T
Joseph Doherty 344379a40a fix(utc/locale): close Theme 2 — 8 UTC / time / locale findings
UTC invariant + culture-safety fixes across UI form binding, audit entity
hydrate, and locale-dependent parses. Highlights:
- CentralUI-026/027: AuditFilterBar / SiteCallsReport / NotificationReport /
  EventLogs now apply SpecifyKind(Local) + ToUniversalTime() at form submit
  so browser-local datetime-local inputs aren't silently treated as UTC.
- Commons-019: AuditEvent.OccurredAtUtc / IngestedAtUtc init-setters
  re-tag any incoming DateTime as Kind=Utc, documenting the invariant.
- CD-018: AuditLogEntityTypeConfiguration adds UTC ValueConverters on the
  *Utc DateTime columns so EF hydrate yields Kind=Utc (SQL Server's
  datetime2 has no Kind metadata, so reads were returning Unspecified).
- CD-020: GetPartitionBoundariesOlderThanAsync now SpecifyKind(Utc) on the
  raw-ADO read, matching the existing defence in AuditLogPartitionMaintenance.
- SEL-021: EventLogQueryService.DateTimeOffset.Parse now uses
  InvariantCulture + AssumeUniversal | AdjustToUniversal.
- SR-023: Convert.ToDouble in ScriptActor + AlarmActor (4 sites) now
  passes InvariantCulture so non-US locales don't mis-parse string values.
- HM-020: CentralHealthAggregator.MarkHeartbeat anchors LastHeartbeatAt to
  max(receivedAt, now) on offline→online so a stale receivedAt can't
  leave a recovered site one tick from re-going-offline.

3 new tests added (AuditLog UTC converter, AuditFilterBar/EventLogs/
NotificationReport-touching CentralUI tests already cover Apply paths,
heartbeat offline→online). Build clean; ConfigurationDatabase 236,
Commons 330, HealthMonitoring 71, SiteRuntime 301, SiteEventLogging 50,
CentralUI 50 — all green. README regenerated: 104 open (was 112).
2026-05-28 06:36:44 -04:00

193 lines
8.0 KiB
C#

using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.RemoteQuery;
namespace ScadaLink.SiteEventLogging;
/// <summary>
/// Processes event log queries locally against SQLite.
/// Supports filtering by event_type, time range, instance_id, severity,
/// and keyword search (LIKE on message and source).
/// Uses keyset pagination with continuation token (last event ID).
/// </summary>
public class EventLogQueryService : IEventLogQueryService
{
private readonly SiteEventLogger _eventLogger;
private readonly SiteEventLogOptions _options;
private readonly ILogger<EventLogQueryService> _logger;
/// <summary>Initializes a new instance of <see cref="EventLogQueryService"/>.</summary>
/// <param name="eventLogger">The concrete event logger providing lock-guarded database access.</param>
/// <param name="options">Site event log options (page size and other query settings).</param>
/// <param name="logger">Logger instance.</param>
public EventLogQueryService(
SiteEventLogger eventLogger,
IOptions<SiteEventLogOptions> options,
ILogger<EventLogQueryService> logger)
{
// Depend on the concrete recorder directly: queries must funnel database
// access through its lock-guarded WithConnection. Taking ISiteEventLogger and
// downcasting would throw InvalidCastException for any other implementation.
_eventLogger = eventLogger;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Escapes the SQL <c>LIKE</c> metacharacters (<c>\</c>, <c>%</c>, <c>_</c>) in a
/// user-supplied keyword so it is matched as a literal substring. Used together
/// with a <c>LIKE ... ESCAPE '\'</c> clause.
/// </summary>
private static string EscapeLikePattern(string input)
{
return input
.Replace("\\", "\\\\")
.Replace("%", "\\%")
.Replace("_", "\\_");
}
/// <inheritdoc />
public EventLogQueryResponse ExecuteQuery(EventLogQueryRequest request)
{
try
{
var pageSize = request.PageSize > 0 ? request.PageSize : _options.QueryPageSize;
var whereClauses = new List<string>();
var parameters = new List<SqliteParameter>();
// Keyset pagination: only return events with id > continuation token
if (request.ContinuationToken.HasValue)
{
whereClauses.Add("id > $afterId");
parameters.Add(new SqliteParameter("$afterId", request.ContinuationToken.Value));
}
if (request.From.HasValue)
{
whereClauses.Add("timestamp >= $from");
parameters.Add(new SqliteParameter("$from", request.From.Value.ToString("o")));
}
if (request.To.HasValue)
{
whereClauses.Add("timestamp <= $to");
parameters.Add(new SqliteParameter("$to", request.To.Value.ToString("o")));
}
if (!string.IsNullOrWhiteSpace(request.EventType))
{
whereClauses.Add("event_type = $eventType");
parameters.Add(new SqliteParameter("$eventType", request.EventType));
}
if (!string.IsNullOrWhiteSpace(request.Severity))
{
whereClauses.Add("severity = $severity");
parameters.Add(new SqliteParameter("$severity", request.Severity));
}
if (!string.IsNullOrWhiteSpace(request.InstanceId))
{
whereClauses.Add("instance_id = $instanceId");
parameters.Add(new SqliteParameter("$instanceId", request.InstanceId));
}
if (!string.IsNullOrWhiteSpace(request.KeywordFilter))
{
// Keyword search is a literal substring match. The LIKE
// metacharacters % and _ (and the escape char itself) must be
// escaped so identifiers such as "store_and_forward" or a literal
// "%" are not misinterpreted as wildcards (SiteEventLogging-013).
var escaped = EscapeLikePattern(request.KeywordFilter);
whereClauses.Add(
"(message LIKE $keyword ESCAPE '\\' OR source LIKE $keyword ESCAPE '\\')");
parameters.Add(new SqliteParameter("$keyword", $"%{escaped}%"));
}
var whereClause = whereClauses.Count > 0
? "WHERE " + string.Join(" AND ", whereClauses)
: "";
// Run the read against the shared connection under the logger's write
// lock — the connection is not thread-safe and is also used by the
// recorder and the purge service on other threads.
var entries = _eventLogger.WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
// Fetch pageSize + 1 to determine if there are more results
cmd.CommandText = $"""
SELECT id, timestamp, event_type, severity, instance_id, source, message, details
FROM site_events
{whereClause}
ORDER BY id ASC
LIMIT $limit
""";
cmd.Parameters.AddWithValue("$limit", pageSize + 1);
foreach (var p in parameters)
cmd.Parameters.Add(p);
var rows = new List<EventLogEntry>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(new EventLogEntry(
Id: reader.GetInt64(0),
// Parse with explicit invariant culture and round-trip style
// (SiteEventLogging-021). Stored values are ISO 8601 "o" UTC
// (see SiteEventLogger.LogEventAsync), and the recorder's
// emitted offset is always +00:00; AssumeUniversal +
// AdjustToUniversal guarantees the parsed value is UTC and
// does not depend on the host's CurrentCulture.
Timestamp: DateTimeOffset.Parse(
reader.GetString(1),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
EventType: reader.GetString(2),
Severity: reader.GetString(3),
InstanceId: reader.IsDBNull(4) ? null : reader.GetString(4),
Source: reader.GetString(5),
Message: reader.GetString(6),
Details: reader.IsDBNull(7) ? null : reader.GetString(7)));
}
return rows;
});
var hasMore = entries.Count > pageSize;
if (hasMore)
{
entries.RemoveAt(entries.Count - 1);
}
var continuationToken = entries.Count > 0 ? entries[^1].Id : (long?)null;
return new EventLogQueryResponse(
CorrelationId: request.CorrelationId,
SiteId: request.SiteId,
Entries: entries,
ContinuationToken: continuationToken,
HasMore: hasMore,
Success: true,
ErrorMessage: null,
Timestamp: DateTimeOffset.UtcNow);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to execute event log query: {CorrelationId}", request.CorrelationId);
return new EventLogQueryResponse(
CorrelationId: request.CorrelationId,
SiteId: request.SiteId,
Entries: [],
ContinuationToken: null,
HasMore: false,
Success: false,
ErrorMessage: ex.Message,
Timestamp: DateTimeOffset.UtcNow);
}
}
}