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:
Joseph Doherty
2026-05-28 06:36:44 -04:00
parent 487859bff0
commit 344379a40a
20 changed files with 382 additions and 55 deletions
@@ -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>&lt;input type="datetime-local"&gt;</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>&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",
@@ -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>&lt;input type="datetime-local"&gt;</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),
+10 -3
View File
@@ -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,