178 lines
7.0 KiB
C#
178 lines
7.0 KiB
C#
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;
|
|
|
|
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("_", "\\_");
|
|
}
|
|
|
|
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),
|
|
Timestamp: DateTimeOffset.Parse(reader.GetString(1)),
|
|
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);
|
|
}
|
|
}
|
|
}
|