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).
This commit is contained in:
@@ -167,11 +167,39 @@ public partial class AuditFilterBar
|
||||
|
||||
private async Task Apply()
|
||||
{
|
||||
var now = NowUtcProvider?.Invoke() ?? DateTime.UtcNow;
|
||||
var filter = _model.ToFilter(now);
|
||||
await OnFilterChanged.InvokeAsync(filter);
|
||||
// CentralUI-026: <input type="datetime-local"> binds with DateTimeKind.Unspecified
|
||||
// — the value is the user's browser-local wall-clock. Tag it as Local then convert
|
||||
// to UTC before the model emits the filter, otherwise a non-UTC operator's window
|
||||
// is silently shifted by their UTC offset. Done on a swap-and-restore basis so the
|
||||
// bound inputs still show the user's local picks on the next render.
|
||||
var originalFrom = _model.CustomFromUtc;
|
||||
var originalTo = _model.CustomToUtc;
|
||||
try
|
||||
{
|
||||
_model.CustomFromUtc = LocalInputToUtc(originalFrom);
|
||||
_model.CustomToUtc = LocalInputToUtc(originalTo);
|
||||
var now = NowUtcProvider?.Invoke() ?? DateTime.UtcNow;
|
||||
var filter = _model.ToFilter(now);
|
||||
await OnFilterChanged.InvokeAsync(filter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_model.CustomFromUtc = originalFrom;
|
||||
_model.CustomToUtc = originalTo;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a value bound from <c><input type="datetime-local"></c> (which Blazor
|
||||
/// surfaces as <see cref="DateTimeKind.Unspecified"/>) into UTC. The input represents
|
||||
/// the operator's browser-local wall-clock, so we must tag it <see cref="DateTimeKind.Local"/>
|
||||
/// before <see cref="DateTime.ToUniversalTime"/> can do anything meaningful.
|
||||
/// </summary>
|
||||
private static DateTime? LocalInputToUtc(DateTime? value) =>
|
||||
value.HasValue
|
||||
? DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime()
|
||||
: (DateTime?)null;
|
||||
|
||||
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
|
||||
{
|
||||
AuditTimeRangePreset.Last5Minutes => "now − 5 min → now",
|
||||
|
||||
@@ -258,8 +258,12 @@
|
||||
var request = new EventLogQueryRequest(
|
||||
CorrelationId: Guid.NewGuid().ToString("N"),
|
||||
SiteId: _selectedSiteId,
|
||||
From: _filterFrom.HasValue ? new DateTimeOffset(_filterFrom.Value, TimeSpan.Zero) : null,
|
||||
To: _filterTo.HasValue ? new DateTimeOffset(_filterTo.Value, TimeSpan.Zero) : null,
|
||||
// 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(),
|
||||
@@ -289,6 +293,18 @@
|
||||
_searching = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI-027: convert a value bound from <c><input type="datetime-local"></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",
|
||||
|
||||
@@ -670,8 +670,16 @@
|
||||
|
||||
private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
// CentralUI-027: <input type="datetime-local"> binds with DateTimeKind.Unspecified
|
||||
// — the value is the operator's browser-local wall-clock. Tag it as Local and
|
||||
// convert to UTC before the value enters the wire query; otherwise the From/To
|
||||
// window is silently shifted by the operator's UTC offset.
|
||||
private static DateTimeOffset? ToUtc(DateTime? local) =>
|
||||
local == null ? null : new DateTimeOffset(DateTime.SpecifyKind(local.Value, DateTimeKind.Utc));
|
||||
local.HasValue
|
||||
? new DateTimeOffset(
|
||||
DateTime.SpecifyKind(local.Value, DateTimeKind.Local).ToUniversalTime(),
|
||||
TimeSpan.Zero)
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
private static string ShortId(string id) => id[..Math.Min(12, id.Length)];
|
||||
|
||||
|
||||
@@ -448,11 +448,16 @@ public partial class SiteCallsReport
|
||||
private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
/// <summary>
|
||||
/// The filter inputs are UTC wall-clock — stamp <see cref="DateTimeKind.Utc"/>
|
||||
/// on the local-typed value so the query is unambiguous.
|
||||
/// CentralUI-027: <c><input type="datetime-local"></c> binds with
|
||||
/// <see cref="DateTimeKind.Unspecified"/> and the value is the operator's
|
||||
/// browser-local wall-clock. Tag it <see cref="DateTimeKind.Local"/> and
|
||||
/// convert to UTC before the value enters the wire query — otherwise the
|
||||
/// From/To window is silently shifted by the operator's UTC offset.
|
||||
/// </summary>
|
||||
private static DateTime? ToUtc(DateTime? value) =>
|
||||
value == null ? null : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
|
||||
value.HasValue
|
||||
? DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime()
|
||||
: (DateTime?)null;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>SiteCalls</c> timestamps are UTC <see cref="DateTime"/>; wrap them as
|
||||
|
||||
@@ -6,16 +6,57 @@ namespace ScadaLink.Commons.Entities.Audit;
|
||||
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
|
||||
/// site rows leave IngestedAtUtc null until ingest. Append-only.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties on this record are
|
||||
/// invariantly UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
|
||||
/// Their init-setters call <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>
|
||||
/// to force <see cref="DateTimeKind.Utc"/> on assignment, so a value built from a
|
||||
/// <c>DateTime</c> literal or re-hydrated from a SQL Server <c>datetime2</c> column
|
||||
/// (which strips the <c>Kind</c> flag on the wire) cannot leak downstream as
|
||||
/// <see cref="DateTimeKind.Unspecified"/> or be silently re-interpreted as local
|
||||
/// time. The unrelated <see cref="ScadaLink.Commons.Entities.Notifications"/>
|
||||
/// surface uses <see cref="DateTimeOffset"/> for the same UTC guarantee; this
|
||||
/// entity stays on <see cref="DateTime"/> to match the partitioned SQL Server
|
||||
/// <c>datetime2</c> column shape required by the AuditLog table.
|
||||
/// </remarks>
|
||||
public sealed record AuditEvent
|
||||
{
|
||||
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp when the audited action occurred at its source.</summary>
|
||||
public DateTime OccurredAtUtc { get; init; }
|
||||
/// <summary>
|
||||
/// UTC timestamp when the audited action occurred at its source. The value
|
||||
/// MUST be in UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
|
||||
/// The init-setter forces <see cref="DateTimeKind.Utc"/> on assignment via
|
||||
/// <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>, so any
|
||||
/// construction path that supplies a value with <see cref="DateTimeKind.Unspecified"/>
|
||||
/// (e.g. a <c>DateTime</c> literal, JSON deserialisation, or a SQL Server
|
||||
/// <c>datetime2</c> read where the value bypassed the EF converter) is
|
||||
/// re-tagged as UTC rather than treated as local time downstream. Producers
|
||||
/// are still expected to supply values that ARE genuinely UTC — the setter
|
||||
/// only fixes the <c>Kind</c> flag, it cannot re-interpret a local-time value.
|
||||
/// </summary>
|
||||
public DateTime OccurredAtUtc
|
||||
{
|
||||
get => _occurredAtUtc;
|
||||
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
}
|
||||
private readonly DateTime _occurredAtUtc;
|
||||
|
||||
/// <summary>UTC timestamp when the row was ingested at central; null on the site hot-path.</summary>
|
||||
public DateTime? IngestedAtUtc { get; init; }
|
||||
/// <summary>
|
||||
/// UTC timestamp when the row was ingested at central; null on the site hot-path.
|
||||
/// The value MUST be in UTC when non-null; the init-setter forces
|
||||
/// <see cref="DateTimeKind.Utc"/> on assignment, matching
|
||||
/// <see cref="OccurredAtUtc"/>'s contract.
|
||||
/// </summary>
|
||||
public DateTime? IngestedAtUtc
|
||||
{
|
||||
get => _ingestedAtUtc;
|
||||
init => _ingestedAtUtc = value.HasValue
|
||||
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
|
||||
: null;
|
||||
}
|
||||
private readonly DateTime? _ingestedAtUtc;
|
||||
|
||||
/// <summary>Trust-boundary channel the audited action crossed.</summary>
|
||||
public AuditChannel Channel { get; init; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
@@ -11,12 +12,38 @@ namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
/// </summary>
|
||||
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
|
||||
{
|
||||
// SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
|
||||
// (a column hydrated from the database always surfaces as
|
||||
// DateTimeKind.Unspecified). Without a converter, downstream code that
|
||||
// calls .ToLocalTime() / .ToUniversalTime() on an OccurredAtUtc value would
|
||||
// silently re-interpret it as local time. These converters force the Kind
|
||||
// back to Utc on read, and re-stamp Utc on write so a producer that hands
|
||||
// EF a DateTime literal with Kind=Unspecified still lands a UTC-tagged
|
||||
// value in the model cache (CLAUDE.md: "All timestamps are UTC throughout
|
||||
// the system."). Applied to every DateTime property whose name ends in
|
||||
// `Utc`; DateTimeOffset columns already carry their own offset and are NOT
|
||||
// routed through these converters.
|
||||
private static readonly ValueConverter<DateTime, DateTime> UtcConverter = new(
|
||||
v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
|
||||
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
||||
|
||||
private static readonly ValueConverter<DateTime?, DateTime?> NullableUtcConverter = new(
|
||||
v => v.HasValue
|
||||
? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc))
|
||||
: null,
|
||||
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
|
||||
|
||||
/// <summary>Applies the EF Core type configuration for <see cref="AuditEvent"/> to the model builder.</summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<AuditEvent> builder)
|
||||
{
|
||||
builder.ToTable("AuditLog");
|
||||
|
||||
// Enforce DateTimeKind.Utc on every *Utc-suffixed DateTime column. See
|
||||
// the UtcConverter remarks above for the rationale.
|
||||
builder.Property(e => e.OccurredAtUtc).HasConversion(UtcConverter);
|
||||
builder.Property(e => e.IngestedAtUtc).HasConversion(NullableUtcConverter);
|
||||
|
||||
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
|
||||
// (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
|
||||
// needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
|
||||
|
||||
@@ -383,7 +383,14 @@ VALUES
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(reader.GetDateTime(0));
|
||||
// SQL Server's datetime2 surfaces as DateTimeKind.Unspecified
|
||||
// through ADO.NET (the column type carries no offset/kind).
|
||||
// Boundary values are stored in UTC, so re-tag the kind here —
|
||||
// matches the explicit defence in
|
||||
// AuditLogPartitionMaintenance.GetMaxBoundaryAsync and prevents
|
||||
// downstream .ToLocalTime()/.ToUniversalTime() conversions
|
||||
// from silently treating the value as local time.
|
||||
results.Add(DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc));
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -125,9 +125,28 @@ public class CentralHealthAggregator : BackgroundService, ICentralHealthAggregat
|
||||
continue;
|
||||
}
|
||||
|
||||
var newHeartbeat = receivedAt > existing.LastHeartbeatAt
|
||||
? receivedAt
|
||||
: existing.LastHeartbeatAt;
|
||||
// HealthMonitoring-020: when an offline→online transition is being
|
||||
// applied, the heartbeat timestamp must reflect a fresh observation,
|
||||
// not the prior stored value. If receivedAt is older than the stored
|
||||
// LastHeartbeatAt (clock skew, an out-of-order heartbeat arriving
|
||||
// after an earlier one already advanced the field), promoting the
|
||||
// site back to online while leaving LastHeartbeatAt stale would let
|
||||
// CheckForOfflineSites flap it straight back to offline on the next
|
||||
// tick. Anchor the heartbeat to the current time provider instead,
|
||||
// so an offline-to-online transition is always backed by an
|
||||
// up-to-date heartbeat.
|
||||
DateTimeOffset newHeartbeat;
|
||||
if (!existing.IsOnline)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
newHeartbeat = receivedAt > now ? receivedAt : now;
|
||||
}
|
||||
else
|
||||
{
|
||||
newHeartbeat = receivedAt > existing.LastHeartbeatAt
|
||||
? receivedAt
|
||||
: existing.LastHeartbeatAt;
|
||||
}
|
||||
|
||||
// Nothing to change — avoid a needless swap.
|
||||
if (newHeartbeat == existing.LastHeartbeatAt && existing.IsOnline)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -135,7 +136,16 @@ public class EventLogQueryService : IEventLogQueryService
|
||||
{
|
||||
rows.Add(new EventLogEntry(
|
||||
Id: reader.GetInt64(0),
|
||||
Timestamp: DateTimeOffset.Parse(reader.GetString(1)),
|
||||
// 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),
|
||||
|
||||
@@ -6,6 +6,7 @@ using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Actors;
|
||||
@@ -337,7 +338,9 @@ public class AlarmActor : ReceiveActor
|
||||
|
||||
try
|
||||
{
|
||||
var numericValue = Convert.ToDouble(value);
|
||||
// InvariantCulture so string attribute values parse consistently
|
||||
// regardless of host locale (SiteRuntime-023).
|
||||
var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
|
||||
return numericValue < config.Min || numericValue > config.Max;
|
||||
}
|
||||
catch
|
||||
@@ -353,7 +356,9 @@ public class AlarmActor : ReceiveActor
|
||||
|
||||
try
|
||||
{
|
||||
var numericValue = Convert.ToDouble(value);
|
||||
// InvariantCulture so string attribute values parse consistently
|
||||
// regardless of host locale (SiteRuntime-023).
|
||||
var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
|
||||
|
||||
// Add to window
|
||||
_rateOfChangeWindow.Enqueue((timestamp, numericValue));
|
||||
@@ -441,7 +446,9 @@ public class AlarmActor : ReceiveActor
|
||||
if (value == null) return _currentLevel;
|
||||
|
||||
double numericValue;
|
||||
try { numericValue = Convert.ToDouble(value); }
|
||||
// InvariantCulture so string attribute values parse consistently
|
||||
// regardless of host locale (SiteRuntime-023).
|
||||
try { numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture); }
|
||||
catch { return _currentLevel; }
|
||||
|
||||
// When the current level is at-or-above HighHigh, relax the HiHi exit.
|
||||
|
||||
@@ -8,6 +8,7 @@ using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
using ScadaLink.SiteEventLogging;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Actors;
|
||||
@@ -443,7 +444,12 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
|
||||
try
|
||||
{
|
||||
var numericValue = Convert.ToDouble(value);
|
||||
// Use InvariantCulture so a string attribute value like "1.5" parses
|
||||
// consistently regardless of the host locale (SiteRuntime-023). For
|
||||
// purely-numeric inputs the culture argument is a no-op, but it is
|
||||
// safe and future-proof for string-typed attribute values arriving
|
||||
// from scripts or the data connection layer.
|
||||
var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
|
||||
return config.Operator switch
|
||||
{
|
||||
">" => numericValue > config.Threshold,
|
||||
|
||||
Reference in New Issue
Block a user