From 9e5e32d0f2c51f01b3245442391fd1c166e29362 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 15:34:44 -0400 Subject: [PATCH 01/23] docs(audit): add SourceNode column to AuditLog/Notifications/SiteCalls design + plan - Adds SourceNode varchar(64) NULL to AuditLog, Notifications, and SiteCalls tables with role-name semantics: node-a/node-b for site rows (qualified by SourceSiteId), central-a/central-b for central direct-write rows. - New IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc) index. - Reframes CLAUDE.md from documentation-only to implementation project. - Adds docs/plans/2026-05-23-audit-source-node.md + tasks.json companion. --- CLAUDE.md | 24 +- docs/plans/2026-05-23-audit-source-node.md | 931 ++++++++++++++++++ ...2026-05-23-audit-source-node.md.tasks.json | 27 + docs/requirements/Component-AuditLog.md | 10 +- .../Component-NotificationOutbox.md | 1 + docs/requirements/Component-SiteCallAudit.md | 4 + 6 files changed, 986 insertions(+), 11 deletions(-) create mode 100644 docs/plans/2026-05-23-audit-source-node.md create mode 100644 docs/plans/2026-05-23-audit-source-node.md.tasks.json diff --git a/CLAUDE.md b/CLAUDE.md index 4fe1684..75eccee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,17 +1,21 @@ -# ScadaLink Design Documentation Project +# ScadaLink Implementation Project -This project contains design documentation for a distributed SCADA system built on Akka.NET. The documents describe a hub-and-spoke architecture with a central cluster and multiple site clusters. +This is the full **implementation** project for ScadaLink — a distributed SCADA system built on Akka.NET in a hub-and-spoke architecture (one central cluster + multiple site clusters). It contains source code, tests, deployable docker topology, and the design documentation that the code implements. The design docs are the spec; `src/` is the binary. + +When a change is requested, the default assumption is: update the design doc *and* the code *and* the tests *and* (if it ships) the docker deploy — together, in one session, with `git diff` review before committing. ## Project Structure +- `src/` — C#/.NET implementation, one project per component (e.g. `ScadaLink.AuditLog`, `ScadaLink.NotificationOutbox`, `ScadaLink.SiteCallAudit`, `ScadaLink.CentralUI`, `ScadaLink.Host`, …). Solution file: `ScadaLink.slnx`. +- `tests/` — Test projects (unit + integration). +- `docker/` — 8-node cluster topology (2 central + 3 sites), `deploy.sh`, per-node `appsettings.*.json`. See [`docker/README.md`](docker/README.md) for setup, ports, and management commands. Rebuild + redeploy with `bash docker/deploy.sh`. +- `infra/` — Docker Compose for local test services (LDAP, MS SQL, OPC UA, SMTP, REST API, Traefik). - `README.md` — Master index with component table and architecture diagrams. - `docs/requirements/HighLevelReqs.md` — Complete high-level requirements covering all functional areas. -- `docs/requirements/Component-*.md` — Individual component design documents (one per component). +- `docs/requirements/Component-*.md` — Individual component design documents (one per component) — the spec the code implements. - `docs/test_infra/test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL, SMTP, REST API, Traefik). -- `docs/plans/` — Design decision documents from refinement sessions. +- `docs/plans/` — Design decision and implementation-plan documents from refinement sessions. - `AkkaDotNet/` — Akka.NET reference documentation and best practices notes. -- `infra/` — Docker Compose and config files for local test services. -- `docker/` — Docker infrastructure for the 8-node cluster topology (2 central + 3 sites). See [`docker/README.md`](docker/README.md) for cluster setup, port allocation, and management commands. ## Document Conventions @@ -31,10 +35,11 @@ This project contains design documentation for a distributed SCADA system built ## Editing Rules -- Edit documents in place. Do not create copies or backup files. -- When a change affects multiple documents, update all affected documents in the same session. +- Edit documents and code in place. Do not create copies or backup files. +- When a change affects multiple documents or projects, update them all in the same session — design doc, entities/repos, actors/services, UI, tests, migrations, and deploy config travel together. - Use `git diff` to review changes before committing. -- Commit related changes together with a descriptive message summarizing the design decision. +- Commit related changes together with a descriptive message summarizing the design decision and the implementation slice. +- After non-trivial code changes, build (`dotnet build ScadaLink.slnx`) and run relevant tests before declaring done; for cluster-runtime changes, rebuild the image with `bash docker/deploy.sh`. ## Current Component List (23 components) @@ -140,6 +145,7 @@ This project contains design documentation for a distributed SCADA system built - Audit-write failure NEVER aborts the user-facing action — audit is best-effort, the action's own success/failure path is authoritative. - 365-day central retention with monthly partition-switch purge; 7-day site SQLite retention with a hard `ForwardState` invariant (no row purged until forwarded or reconciled). - Append-only enforced via DB roles (writer role has INSERT only, no UPDATE/DELETE); hash-chain tamper evidence and Parquet archival are deferred to v1.x. +- Node-of-origin is captured alongside site-of-origin: `SourceNode` (`varchar(64)` NULL) on `AuditLog`, `Notifications`, and `SiteCalls` — `node-a`/`node-b` for site rows (qualified by `SourceSiteId`/`SourceSite`), `central-a`/`central-b` for central direct-write rows. Stamped at the writing node, carried verbatim through telemetry + reconciliation, and indexed via `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` on `AuditLog`. - Central UI: new top-level **Audit** nav group + Audit Log page, with drill-ins from Notifications, Site Calls, External Systems, Inbound API Keys, Sites, and Instances. ### Security & Auth diff --git a/docs/plans/2026-05-23-audit-source-node.md b/docs/plans/2026-05-23-audit-source-node.md new file mode 100644 index 0000000..4c6e278 --- /dev/null +++ b/docs/plans/2026-05-23-audit-source-node.md @@ -0,0 +1,931 @@ +# Audit `SourceNode` Stamping — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Capture the cluster node of origin (`node-a` / `node-b` for site rows, `central-a` / `central-b` for central direct-write rows) on every `AuditLog`, `Notifications`, and `SiteCalls` row, end-to-end from the writing node through telemetry / reconciliation to the Central UI. + +**Architecture:** Introduce a single `INodeIdentityProvider` exposing the local node name from `NodeOptions.NodeName` (new config key, bound from `ScadaLink:Node:NodeName`). Stamp `SourceNode` at the *writing node* — site `SqliteAuditWriter` for site rows, `CentralAuditWriter` for central direct-writes — and carry it verbatim through the existing gRPC `AuditEventDto` / `SiteCallOperationalDto` envelopes (additive new field), the `NotificationSubmit` S&F payload, and the `CachedCallTelemetry` packet. EF Core migrations add the column to all three central tables; site-side SQLite schemas use the existing idempotent `PRAGMA table_info` + `ALTER TABLE ADD COLUMN` pattern. UI gets a new "Node" column + filter on the three grids. + +**Tech Stack:** .NET 8 / C# 12 sealed records, EF Core 8 (MS SQL on central, SQLite at sites), Akka.NET, Grpc.AspNetCore, Blazor Server, xUnit + Akka.TestKit + NSubstitute + Playwright. Migration naming: `YYYYMMDDHHmmss_DescriptiveTitle`. + +**Out of scope (deferred):** +- Cross-table `SourceNode`-aware KPIs (e.g., "per-node stuck count"). The data is captured; dashboards stay site-keyed until a real ask lands. +- Backfilling existing rows with a best-guess `SourceNode`. New rows get the column; legacy rows stay `NULL`. +- Renaming `NodeHostname` (the Docker container hostname) — it stays as the diagnostic hostname; `NodeName` is the new semantic role-within-cluster name. + +--- + +## Conventions + +- All file paths are relative to repo root (`/Users/dohertj2/Desktop/scadalink-design`). +- TDD: red → green → commit. Failing test first, then implementation, then verify, then commit. +- One coherent change per commit. Commit messages prefix with the affected slice: `feat(audit):`, `feat(notif-outbox):`, `feat(sitecall-audit):`, `chore(docker):`, etc. +- After each task, run the targeted test set (`dotnet test --filter `) and the full solution build (`dotnet build ScadaLink.slnx`) before the commit. +- For tasks whose migration name needs a timestamp, use `date -u +%Y%m%d%H%M%S` at the moment of execution. The exact name doesn't matter for correctness; just keep them monotonically ordered. + +--- + +## Task 0: Branch + Snapshot + +**Files:** none (git only) + +**Step 1: Create feature branch** + +```bash +git checkout -b feature/audit-source-node +git status +``` + +Expected: branch created, working tree shows only the prior design-doc edits + the modified `appsettings.*.json` files already in `git status`. + +**Step 2: Stash unrelated dirty files** + +`docker/central-node-{a,b}/appsettings.Central.json` and `src/ScadaLink.CentralUI/Components/Pages/Login.razor` are dirty from prior unrelated work — do **NOT** include them in this feature's commits. Verify with `git diff --stat` what's already modified, and either revert or stash: + +```bash +git diff --stat +git stash push -- docker/central-node-a/appsettings.Central.json docker/central-node-b/appsettings.Central.json src/ScadaLink.CentralUI/Components/Pages/Login.razor +``` + +Expected: clean diff against the prior commit + the design-doc edits from this session. + +**Step 3: Baseline build + tests** + +```bash +dotnet build ScadaLink.slnx +dotnet test tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj +dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj +dotnet test tests/ScadaLink.SiteCallAudit.Tests/ScadaLink.SiteCallAudit.Tests.csproj +``` + +Expected: green. If any are red on `main`, **STOP** and surface that to the user — the plan assumes a green starting point. + +**Step 4: Commit the in-progress design-doc edits from this session** + +```bash +git add CLAUDE.md docs/requirements/Component-AuditLog.md docs/requirements/Component-NotificationOutbox.md docs/requirements/Component-SiteCallAudit.md docs/plans/2026-05-23-audit-source-node.md +git commit -m "docs(audit): add SourceNode column to AuditLog/Notifications/SiteCalls design + plan" +``` + +--- + +## Task 1: NodeOptions + INodeIdentityProvider + +**Files:** +- Modify: `src/ScadaLink.Host/NodeOptions.cs` +- Create: `src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs` +- Create: `src/ScadaLink.Host/NodeIdentityProvider.cs` +- Modify: `src/ScadaLink.Host/SiteServiceRegistration.cs` (and central registration if separate — check `CentralServiceRegistration.cs` if it exists) +- Create test: `tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs` (if Host.Tests doesn't exist, place under `tests/ScadaLink.AuditLog.Tests/Configuration/NodeIdentityProviderTests.cs` instead) + +**Step 1: Failing test — provider returns configured NodeName** + +```csharp +[Fact] +public void NodeIdentityProvider_returns_configured_NodeName() +{ + var opts = Options.Create(new NodeOptions { NodeName = "central-a", Role = "Central" }); + var provider = new NodeIdentityProvider(opts); + Assert.Equal("central-a", provider.NodeName); +} + +[Fact] +public void NodeIdentityProvider_returns_null_when_NodeName_unset() +{ + var opts = Options.Create(new NodeOptions { NodeName = "", Role = "Central" }); + var provider = new NodeIdentityProvider(opts); + Assert.Null(provider.NodeName); +} +``` + +Run: `dotnet test tests/ --filter NodeIdentityProvider` → expected FAIL (types don't exist yet). + +**Step 2: Add `NodeName` to `NodeOptions`** + +```csharp +public class NodeOptions +{ + public string Role { get; set; } = string.Empty; + public string NodeHostname { get; set; } = string.Empty; + public string NodeName { get; set; } = string.Empty; // <— new + public string? SiteId { get; set; } + public int RemotingPort { get; set; } = 8081; + public int GrpcPort { get; set; } = 8083; +} +``` + +**Step 3: Create `INodeIdentityProvider` + implementation** + +```csharp +// src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs +namespace ScadaLink.Commons.Interfaces.Services; + +public interface INodeIdentityProvider +{ + /// + /// Semantic role-within-cluster name of the local node — `node-a` / `node-b` + /// for site nodes, `central-a` / `central-b` for central nodes. NULL when + /// unconfigured (development/legacy hosts). + /// + string? NodeName { get; } +} +``` + +```csharp +// src/ScadaLink.Host/NodeIdentityProvider.cs +internal sealed class NodeIdentityProvider : INodeIdentityProvider +{ + public NodeIdentityProvider(IOptions options) + { + var name = options.Value.NodeName; + NodeName = string.IsNullOrWhiteSpace(name) ? null : name.Trim(); + } + + public string? NodeName { get; } +} +``` + +**Step 4: Register the singleton in DI** + +In `SiteServiceRegistration.cs` and `CentralServiceRegistration.cs` (or wherever `NodeOptions` is bound): + +```csharp +services.AddSingleton(); +``` + +**Step 5: Run tests + build** + +```bash +dotnet test tests/ --filter NodeIdentityProvider -v n +dotnet build ScadaLink.slnx +``` + +Expected: PASS, solution builds. + +**Step 6: Commit** + +```bash +git add src/ScadaLink.Host/NodeOptions.cs \ + src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs \ + src/ScadaLink.Host/NodeIdentityProvider.cs \ + src/ScadaLink.Host/SiteServiceRegistration.cs \ + tests//NodeIdentityProviderTests.cs +git commit -m "feat(host): add NodeName to NodeOptions + INodeIdentityProvider" +``` + +--- + +## Task 2: Add `SourceNode` to `AuditEvent` record + +**Files:** +- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` +- Modify: any direct constructors of `AuditEvent` (compile errors will surface them) +- Modify: `tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs` (or the closest equivalent) — add a SourceNode round-trip assertion. + +**Step 1: Failing test** + +```csharp +[Fact] +public void AuditEvent_carries_SourceNode_through_with_init() +{ + var ev = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + SourceSiteId = "site-a", + SourceNode = "node-a", + }; + + Assert.Equal("node-a", ev.SourceNode); +} +``` + +Run: expected FAIL (property doesn't exist). + +**Step 2: Add `SourceNode` to the record** + +In `AuditEvent.cs`, add between `SourceSiteId` and `SourceInstanceId` (mirroring the design doc): + +```csharp +public string? SourceNode { get; init; } +``` + +**Step 3: Resolve compile errors** + +The record is `sealed` and used widely. Most usages will be init-only and won't break; positional constructors (if any) will. Fix them by adding `SourceNode = …` initializers OR by leaving `SourceNode = null` where the caller doesn't know the node yet (writer-level stamping happens in Task 9 + 10). + +**Step 4: Run + commit** + +```bash +dotnet test tests/ScadaLink.AuditLog.Tests --filter SourceNode -v n +dotnet build ScadaLink.slnx +git add src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs +git commit -m "feat(audit): add SourceNode property to AuditEvent record" +``` + +--- + +## Task 3: Add `SourceNode` to `SiteCallOperational` + `SiteCall` entity + +**Files:** +- Modify: `src/ScadaLink.Commons/Types/SiteCallOperational.cs` +- Modify: `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs` +- Modify: tests in `tests/ScadaLink.SiteCallAudit.Tests/` that build a `SiteCall` — extend at least one to assert SourceNode is carried. + +**Step 1: Failing test — `SiteCallOperational` constructed with SourceNode** + +```csharp +[Fact] +public void SiteCallOperational_carries_SourceNode() +{ + var op = new SiteCallOperational( + TrackedOperationId: TrackedOperationId.New(), + Channel: "ApiOutbound", + Target: "ERP.GetOrder", + SourceSite: "site-a", + SourceNode: "node-a", // new positional arg + Status: "Submitted", + RetryCount: 0, + LastError: null, + HttpStatus: null, + CreatedAtUtc: DateTime.UtcNow, + UpdatedAtUtc: DateTime.UtcNow, + TerminalAtUtc: null); + + Assert.Equal("node-a", op.SourceNode); +} +``` + +Run: expected FAIL. + +**Step 2: Add `SourceNode` to `SiteCallOperational`** + +Insert `string? SourceNode` between `SourceSite` and `Status`. **Update all callers** — the C# compiler will list every site that constructs the record. Most are in: +- `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs` +- `src/ScadaLink.DataConnectionLayer/...` (cached DB write site) +- `src/ScadaLink.Communication/...` (mappers) +- `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` +- All `tests/ScadaLink.SiteCallAudit.Tests/*` and `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs` + +For now, pass `SourceNode: null` at every existing call site — actual stamping comes in Task 11. + +**Step 3: Add `SourceNode` to `SiteCall` entity** + +Mirror in `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs` as `public string? SourceNode { get; init; }`. + +**Step 4: Run + commit** + +```bash +dotnet build ScadaLink.slnx +dotnet test tests/ScadaLink.SiteCallAudit.Tests --filter SourceNode -v n +git add src/ScadaLink.Commons/Types/SiteCallOperational.cs \ + src/ScadaLink.Commons/Entities/Audit/SiteCall.cs \ + +git commit -m "feat(sitecall-audit): add SourceNode to SiteCallOperational + SiteCall entity" +``` + +--- + +## Task 4: Add `SourceNode` to `Notification` entity + `NotificationSubmit` message + +**Files:** +- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` +- Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs` +- Modify: tests in `tests/ScadaLink.NotificationOutbox.Tests/` + +**Step 1: Failing test** + +```csharp +[Fact] +public void NotificationSubmit_carries_SourceNode() +{ + var submit = new NotificationSubmit( + NotificationId: Guid.NewGuid().ToString("D"), + ListName: "ops-team", + Subject: "x", + Body: "y", + SourceSiteId: "site-a", + SourceInstanceId: "instance-1", + SourceScript: "OnAlarm", + SourceNode: "node-a", // new + SiteEnqueuedAt: DateTimeOffset.UtcNow); + + Assert.Equal("node-a", submit.SourceNode); +} +``` + +Run: expected FAIL. + +**Step 2: Add `SourceNode` to both** + +In `Notification.cs`: `public string? SourceNode { get; set; }`. + +In `NotificationMessages.cs`, extend the record additively (defaulted optional positional arg so existing callers compile — keep behind the optional `OriginExecutionId`/`OriginParentExecutionId` to preserve the existing tail): + +```csharp +public record NotificationSubmit( + string NotificationId, + string ListName, + string Subject, + string Body, + string SourceSiteId, + string? SourceInstanceId, + string? SourceScript, + DateTimeOffset SiteEnqueuedAt, + Guid? OriginExecutionId = null, + Guid? OriginParentExecutionId = null, + string? SourceNode = null); // new, tail +``` + +**Step 3: Run + commit** + +```bash +dotnet build ScadaLink.slnx +dotnet test tests/ScadaLink.NotificationOutbox.Tests --filter SourceNode -v n +git add src/ScadaLink.Commons/Entities/Notifications/Notification.cs \ + src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs \ + tests/ScadaLink.NotificationOutbox.Tests/.cs +git commit -m "feat(notif-outbox): add SourceNode to Notification entity + NotificationSubmit" +``` + +--- + +## Task 5: Add `source_node` to proto + update DTO mappers + +**Files:** +- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` +- Modify: `src/ScadaLink.Communication/AuditEventDtoMapper.cs` (or wherever `ToDto` / `FromDto` live) +- Modify: `src/ScadaLink.Communication/SiteCallOperationalDtoMapper.cs` (likewise) +- Modify: `tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs` +- Modify: equivalent SiteCallOperational proto round-trip tests + +**Step 1: Failing test — proto round-trip preserves SourceNode** + +```csharp +[Fact] +public void AuditEventDto_round_trip_preserves_SourceNode() +{ + var ev = new AuditEvent { /* … */ SourceNode = "node-a", SourceSiteId = "site-a" }; + var dto = AuditEventDtoMapper.ToDto(ev); + var back = AuditEventDtoMapper.FromDto(dto); + Assert.Equal("node-a", back.SourceNode); +} + +[Fact] +public void AuditEventDto_round_trip_preserves_null_SourceNode() +{ + var ev = new AuditEvent { /* … */ SourceNode = null }; + var dto = AuditEventDtoMapper.ToDto(ev); + var back = AuditEventDtoMapper.FromDto(dto); + Assert.Null(back.SourceNode); +} +``` + +Run: expected FAIL (compile error on `SourceNode` until mapper handles it). + +**Step 2: Extend proto additively** + +In `sitestream.proto`, **field numbers must be new and not reused**: + +```proto +message AuditEventDto { + // … existing fields 1..21 unchanged … + string source_node = 22; // empty string represents null +} + +message SiteCallOperationalDto { + // … existing fields 1..11 unchanged … + string source_node = 12; // empty string represents null +} +``` + +**Step 3: Regenerate + update mappers** + +`Grpc.Tools` regenerates on build. Update `AuditEventDtoMapper.ToDto`: + +```csharp +SourceNode = ev.SourceNode ?? string.Empty, +``` + +And `FromDto`: + +```csharp +SourceNode = string.IsNullOrEmpty(dto.SourceNode) ? null : dto.SourceNode, +``` + +Same pattern for `SiteCallOperationalDtoMapper`. + +**Step 4: Run + commit** + +```bash +dotnet build ScadaLink.slnx +dotnet test tests/ScadaLink.Communication.Tests --filter SourceNode -v n +git add src/ScadaLink.Communication/Protos/sitestream.proto \ + src/ScadaLink.Communication/AuditEventDtoMapper.cs \ + src/ScadaLink.Communication/SiteCallOperationalDtoMapper.cs \ + tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs \ + tests/ScadaLink.Communication.Tests/.cs +git commit -m "feat(comm): add source_node field to AuditEventDto + SiteCallOperationalDto proto" +``` + +--- + +## Task 6: EF migration — add `SourceNode` to `AuditLog` + index + +**Files:** +- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddAuditLogSourceNode.cs` + `.Designer.cs` +- Modify: `src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs` +- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` +- Create test: `tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs` + +**Step 1: Failing test (migration apply produces `SourceNode` column + index)** + +Pattern from existing `AddAuditLogTableMigrationTests.cs`. Apply migration against a fresh MS SQL test fixture; assert `INFORMATION_SCHEMA.COLUMNS` contains `SourceNode varchar(64) NULL` and `sys.indexes` contains `IX_AuditLog_Node_Occurred` with columns `(SourceNode, OccurredAtUtc)`. + +Run: expected FAIL. + +**Step 2: Add migration via EF CLI** + +From repo root: + +```bash +dotnet ef migrations add AddAuditLogSourceNode \ + --project src/ScadaLink.ConfigurationDatabase \ + --startup-project src/ScadaLink.Host \ + --context ScadaLinkDbContext +``` + +Hand-edit the generated `Up()` / `Down()` to verify shape: + +```csharp +migrationBuilder.AddColumn( + name: "SourceNode", + table: "AuditLog", + type: "varchar(64)", + unicode: false, + maxLength: 64, + nullable: true); + +migrationBuilder.CreateIndex( + name: "IX_AuditLog_Node_Occurred", + table: "AuditLog", + columns: new[] { "SourceNode", "OccurredAtUtc" }); +``` + +`Down()` drops the index then the column. + +**Step 3: Update EF configuration** + +In `AuditLogEntityTypeConfiguration.cs`, mirror the design doc: + +```csharp +builder.Property(e => e.SourceNode).HasColumnType("varchar(64)").HasMaxLength(64); +builder.HasIndex(e => new { e.SourceNode, e.OccurredAtUtc }) + .HasDatabaseName("IX_AuditLog_Node_Occurred"); +``` + +**Step 4: Run + commit** + +```bash +dotnet build ScadaLink.slnx +dotnet test tests/ScadaLink.ConfigurationDatabase.Tests --filter AuditLogSourceNode -v n +git add src/ScadaLink.ConfigurationDatabase/Migrations/_AddAuditLogSourceNode.cs \ + src/ScadaLink.ConfigurationDatabase/Migrations/_AddAuditLogSourceNode.Designer.cs \ + src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs \ + src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs \ + tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs +git commit -m "feat(db): add SourceNode column + IX_AuditLog_Node_Occurred index to AuditLog" +``` + +--- + +## Task 7: EF migration — add `SourceNode` to `Notifications` + +**Files:** +- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddNotificationSourceNode.cs` + `.Designer.cs` +- Modify: `src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs` +- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs` +- Create test: `tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationSourceNodeMigrationTests.cs` + +**Step 1–4:** Mirror Task 6 for the `Notifications` table. No new index (the spec says index only on `AuditLog`). + +```bash +git commit -m "feat(db): add SourceNode column to Notifications" +``` + +--- + +## Task 8: EF migration — add `SourceNode` to `SiteCalls` + +**Files:** equivalents under `SiteCalls`. No new index. + +**Step 1–4:** Mirror Task 6. Configuration file is `SiteCallEntityTypeConfiguration.cs`. + +```bash +git commit -m "feat(db): add SourceNode column to SiteCalls" +``` + +--- + +## Task 9: Site SQLite `AuditLog` — add `SourceNode` column (idempotent upgrade) + +**Files:** +- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` +- Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` +- Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs` + +**Step 1: Failing test — schema includes SourceNode AND old DBs are upgraded** + +```csharp +[Fact] +public async Task Initialize_creates_AuditLog_with_SourceNode_column() +{ + using var writer = new SqliteAuditWriter(/*…in-memory…*/); + var cols = await ReadColumnsAsync("AuditLog"); + Assert.Contains("SourceNode", cols); +} + +[Fact] +public async Task Initialize_adds_SourceNode_to_pre_existing_schema() +{ + // 1. open a SQLite file and create the OLD schema (no SourceNode) + // 2. open SqliteAuditWriter against the same file + // 3. assert SourceNode column now exists via PRAGMA table_info +} +``` + +Run: expected FAIL. + +**Step 2: Update `InitializeSchema`** + +Add `SourceNode TEXT NULL` to the `CREATE TABLE IF NOT EXISTS AuditLog (...)` DDL. Add a second `PRAGMA table_info`-based upgrade block matching the existing `ExecutionId` / `ParentExecutionId` pattern: + +```csharp +if (!columns.Contains("SourceNode", StringComparer.OrdinalIgnoreCase)) +{ + using var cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER TABLE AuditLog ADD COLUMN SourceNode TEXT NULL;"; + cmd.ExecuteNonQuery(); +} +``` + +**Step 3: Update INSERT statement to include SourceNode** + +In the parameterized batch insert SQL (lines ~270-284), add the column + parameter. + +**Step 4: Run + commit** + +```bash +dotnet test tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs --filter SourceNode -v n +dotnet build ScadaLink.slnx +git add src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs \ + tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs \ + tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs +git commit -m "feat(audit): add SourceNode column to site SQLite AuditLog (idempotent upgrade)" +``` + +--- + +## Task 10: Site SQLite `OperationTracking` — add `SourceNode` column + +**Files:** +- Modify: `src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs` +- Modify: tests under `tests/ScadaLink.SiteRuntime.Tests/Tracking/` (or closest) + +**Step 1: Failing test** — same pattern as Task 9, asserting the `OperationTracking` table grows a `SourceNode TEXT NULL` column on both fresh and pre-existing DBs. + +**Step 2: Add column to CREATE TABLE + idempotent PRAGMA-based ALTER** + +```sql +CREATE TABLE IF NOT EXISTS OperationTracking ( + -- ...existing columns... + SourceNode TEXT NULL +); +``` + +Plus the same PRAGMA upgrade block. + +**Step 3: Update `RecordEnqueueAsync` signature** + +Accept `string? sourceNode` and pass through to INSERT. + +**Step 4: Run + commit** + +```bash +git commit -m "feat(site-runtime): add SourceNode column to OperationTracking + thread through RecordEnqueueAsync" +``` + +--- + +## Task 11: Stamp `SourceNode` at the site `SqliteAuditWriter` + +**Files:** +- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` +- Modify: DI registration that wires `SqliteAuditWriter` (likely `src/ScadaLink.AuditLog/AuditLogServiceCollectionExtensions.cs` or equivalent) +- Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs` + +**Step 1: Failing test** + +```csharp +[Fact] +public async Task WriteAsync_stamps_SourceNode_from_INodeIdentityProvider_when_event_has_none() +{ + var nodeId = Substitute.For(); + nodeId.NodeName.Returns("node-a"); + using var writer = new SqliteAuditWriter(/*…*/, nodeId); + await writer.WriteAsync(new AuditEvent { /*…*/ SourceNode = null }); + var rows = await ReadAllAsync(); + Assert.Equal("node-a", rows.Single().SourceNode); +} + +[Fact] +public async Task WriteAsync_preserves_caller_provided_SourceNode() +{ + var nodeId = Substitute.For(); + nodeId.NodeName.Returns("node-a"); + using var writer = new SqliteAuditWriter(/*…*/, nodeId); + await writer.WriteAsync(new AuditEvent { /*…*/ SourceNode = "node-z" }); + var rows = await ReadAllAsync(); + Assert.Equal("node-z", rows.Single().SourceNode); // caller wins (e.g. reconciliation) +} +``` + +Run: expected FAIL. + +**Step 2: Implementation** + +Inject `INodeIdentityProvider` into `SqliteAuditWriter`. In `WriteAsync` / batch flush, **before** binding parameters: + +```csharp +var stamped = ev.SourceNode is null ? ev with { SourceNode = _nodeIdentity.NodeName } : ev; +``` + +**Step 3: Run + commit** + +```bash +git commit -m "feat(audit): stamp SourceNode at site SqliteAuditWriter from INodeIdentityProvider" +``` + +--- + +## Task 12: Stamp `SourceNode` at central `CentralAuditWriter` + +**Files:** +- Modify: `src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs` +- Modify: `tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs` + +**Step 1: Failing test** — mirror Task 11 but for the central writer (Inbound API / Notification Outbox dispatcher path). + +**Step 2: Implementation** — inject `INodeIdentityProvider`, stamp before `repo.InsertIfNotExistsAsync`. Same "caller wins" semantics. + +**Step 3: Update `AuditLogRepository.InsertIfNotExistsAsync` to persist `SourceNode`** + +This is in `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — extend the parameterized INSERT. + +**Step 4: Run + commit** + +```bash +git commit -m "feat(audit): stamp SourceNode at CentralAuditWriter + persist via AuditLogRepository" +``` + +--- + +## Task 13: Site → central — carry `SourceNode` on `NotificationSubmit` + +**Files:** +- Modify: site code that constructs `NotificationSubmit` (likely under `src/ScadaLink.SiteRuntime/Scripts/` or `src/ScadaLink.NotificationService/Site/...`) +- Modify: S&F buffer schema if NotificationSubmit is serialized to SQLite (check `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs` for a notification-specific column — likely the whole DTO is serialized as a blob, no schema change needed) +- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` `HandleSubmit` to copy `SourceNode` into the `Notification` row +- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs` (or wherever) `InsertIfNotExistsAsync` SQL +- Modify: tests in `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs` + +**Step 1: Failing test — central persists SourceNode from NotificationSubmit** + +```csharp +[Fact] +public async Task HandleSubmit_persists_SourceNode_from_payload() +{ + var submit = new NotificationSubmit(/*…*/, SourceNode: "node-b"); + actor.Tell(submit); + await ExpectMsgAsync(m => m.Accepted); + var row = await repo.GetByIdAsync(submit.NotificationId); + Assert.Equal("node-b", row.SourceNode); +} +``` + +**Step 2: Implementation** + +- Site: inject `INodeIdentityProvider` into the call site that builds `NotificationSubmit`, pass `SourceNode = nodeIdentity.NodeName`. +- Central: extend `HandleSubmit` to copy `submit.SourceNode` onto the `Notification` row; extend the repo INSERT to persist. + +**Step 3: Run + commit** + +```bash +git commit -m "feat(notif-outbox): carry + persist SourceNode end-to-end via NotificationSubmit" +``` + +--- + +## Task 14: Site → central — carry `SourceNode` on `SiteCallOperational` + cached telemetry + +**Files:** +- Modify: site emitters of `SiteCallOperational` — `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs`, `src/ScadaLink.DataConnectionLayer/...` (cached DB write), and the S&F retry path that emits `Attempted` packets +- Modify: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` (and any `SiteCallAuditIngestActor` that maps `SiteCallOperational` → `SiteCall`) +- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs` — extend monotonic upsert SQL to include `SourceNode` +- Modify: tests in `tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs` and `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs` + +**Step 1: Failing test — central persists `SourceNode` on upsert; subsequent upsert with same id keeps SourceNode set even if newer packet has it null** + +```csharp +[Fact] +public async Task Upsert_persists_SourceNode_on_first_insert_and_preserves_on_status_advance() +{ + var id = TrackedOperationId.New(); + await repo.UpsertAsync(new SiteCall { TrackedOperationId = id, /*…*/ SourceNode = "node-a", Status = "Submitted" }); + await repo.UpsertAsync(new SiteCall { TrackedOperationId = id, /*…*/ SourceNode = null, Status = "Delivered" }); + var row = await repo.GetByIdAsync(id); + Assert.Equal("node-a", row.SourceNode); + Assert.Equal("Delivered", row.Status); +} +``` + +**Step 2: Implementation** + +- Site emitters: inject `INodeIdentityProvider`, pass `SourceNode = nodeIdentity.NodeName` on construction. +- Repository: include `SourceNode` in the INSERT branch. In the conditional monotonic UPDATE branch, use `SourceNode = COALESCE(@SourceNode, SourceNode)` so later packets with a null don't blank out a previously-stamped value. + +**Step 3: Run + commit** + +```bash +git commit -m "feat(sitecall-audit): carry + persist SourceNode end-to-end via cached telemetry" +``` + +--- + +## Task 15: Central UI — add Node column + filter to AuditLog grid + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` + `.razor.cs` (add column entry to `AllColumns`) +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (add a text or multi-select Node filter) +- Modify: `src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs` (extend the query/filter model) +- Modify: tests in `tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs` +- Modify: `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs` — assert the Node column renders + +**Step 1: Failing test — query service supports `SourceNode` filter** + +```csharp +[Fact] +public async Task Query_filters_by_SourceNode() +{ + // arrange 2 rows: one with SourceNode=node-a, one with node-b + var rows = await service.QueryAsync(new AuditLogQuery { SourceNodes = new[] { "node-a" } }); + Assert.Single(rows); +} +``` + +**Step 2: Implementation** + +- Extend `AuditLogQuery` with `IReadOnlyList? SourceNodes` (multi-select like `Sites`). +- Extend the LINQ filter (`q.SourceNodes is { Count: > 0 } ? source.Where(r => q.SourceNodes.Contains(r.SourceNode)) : source`). +- Add a new column descriptor `("Node", row => row.SourceNode ?? "—")` between `Site` and `Channel` in `AllColumns`. +- Add a multi-select filter chip in `AuditFilterBar.razor`. Populate node options by `SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL` (cached for 60s). + +**Step 3: Run + commit** + +```bash +git commit -m "feat(ui): add Node column + filter to AuditLog grid" +``` + +--- + +## Task 16: Central UI — add Node column + filter to Notifications grid + +**Files:** mirror Task 15 for `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor` and its query service. No filter populate-from-DB required if scope is per-site already; a free-text Node filter is acceptable for v1. + +```bash +git commit -m "feat(ui): add Node column + filter to NotificationOutbox grid" +``` + +--- + +## Task 17: Central UI — add Node column + filter to SiteCalls grid + +**Files:** mirror Task 15 for `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor`. + +```bash +git commit -m "feat(ui): add Node column + filter to SiteCalls grid" +``` + +--- + +## Task 18: Docker `appsettings` — set `NodeName` on all 8 nodes + +**Files:** +- `docker/central-node-a/appsettings.Central.json` → `"NodeName": "central-a"` +- `docker/central-node-b/appsettings.Central.json` → `"NodeName": "central-b"` +- `docker/site-a-node-a/appsettings.Site.json` → `"NodeName": "node-a"` +- `docker/site-a-node-b/appsettings.Site.json` → `"NodeName": "node-b"` +- `docker/site-b-node-a/appsettings.Site.json` → `"NodeName": "node-a"` +- `docker/site-b-node-b/appsettings.Site.json` → `"NodeName": "node-b"` +- `docker/site-c-node-a/appsettings.Site.json` → `"NodeName": "node-a"` +- `docker/site-c-node-b/appsettings.Site.json` → `"NodeName": "node-b"` + +Add `"NodeName": ""` to the existing `"Node": { … }` object in each file. **Do not** disturb the two `central-*` files that show up dirty in pre-existing `git status` from before this branch — re-apply this change cleanly after stashing. + +```bash +git add docker/central-node-a/appsettings.Central.json docker/central-node-b/appsettings.Central.json docker/site-a-node-a/appsettings.Site.json docker/site-a-node-b/appsettings.Site.json docker/site-b-node-a/appsettings.Site.json docker/site-b-node-b/appsettings.Site.json docker/site-c-node-a/appsettings.Site.json docker/site-c-node-b/appsettings.Site.json +git commit -m "chore(docker): set NodeName on all 8 cluster nodes" +``` + +--- + +## Task 19: Full-solution build + targeted test sweep + +**Step 1: Build** + +```bash +dotnet build ScadaLink.slnx +``` + +Expected: 0 errors, 0 warnings. + +**Step 2: Run the touched test projects** + +```bash +dotnet test tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj +dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj +dotnet test tests/ScadaLink.SiteCallAudit.Tests/ScadaLink.SiteCallAudit.Tests.csproj +dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj +dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj +dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj +``` + +Expected: all green. If anything fails, fix it before proceeding. + +**Step 3: Full solution test (sanity)** + +```bash +dotnet test ScadaLink.slnx --no-build +``` + +--- + +## Task 20: Docker redeploy + smoke verify + +**Step 1: Rebuild + redeploy the cluster** + +```bash +bash docker/deploy.sh +``` + +Expected: image rebuilt, all 8 containers up. Watch for migration apply on central startup. + +**Step 2: CLI smoke — generate one inbound, one notification, one cached call; verify SourceNode populated** + +```bash +# Trigger something that emits each kind. The exact CLI commands depend on +# scripts deployed at the time; at minimum: +# 1. POST to inbound API → produces an InboundRequest row +# 2. Run a script that calls Notify.Send → produces NotifySend + NotifyDeliver +# 3. Run a script that calls ExternalSystem.CachedCall → produces CachedSubmit/Forwarded/Attempted/Delivered + +# Then check SourceNode is populated: +docker exec scadalink-mssql /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'YourStrong!Passw0rd' -Q "SELECT TOP 20 Channel, Kind, SourceSiteId, SourceNode, Status FROM scadalink.dbo.AuditLog ORDER BY OccurredAtUtc DESC" +``` + +Expected: +- `InboundRequest` rows: `SourceSiteId IS NULL`, `SourceNode IN ('central-a','central-b')`. +- `NotifyDeliver` rows: `SourceSiteId IS NULL`, `SourceNode IN ('central-a','central-b')`. +- Site-originated rows: `SourceSiteId = 'site-a'`, `SourceNode IN ('node-a','node-b')`. + +**Step 3: UI smoke** + +Open `http://localhost:9000`, log in as `multi-role` / `password`, hit each of: +- Audit Log → confirm "Node" column shows values, confirm Node filter narrows results. +- Notifications → same. +- Site Calls → same. + +**Step 4: Final commit (if anything had to be tweaked during smoke)** + +```bash +git add -A +git status # verify nothing surprising +git commit -m "chore(audit): smoke-verify SourceNode end-to-end across cluster" +``` + +--- + +## Acceptance Criteria (the whole-plan checklist) + +- [ ] Every audit row written from this commit forward carries `SourceNode` populated (`central-a/b` for central direct-write, `node-a/b` for site rows). +- [ ] Every new `Notifications` and `SiteCalls` row carries `SourceNode` from the site. +- [ ] `IX_AuditLog_Node_Occurred` exists in central MS SQL. +- [ ] Site SQLite `AuditLog` and `OperationTracking` tables both have `SourceNode TEXT NULL`; existing site DBs are upgraded idempotently on startup. +- [ ] Proto `AuditEventDto.source_node = 22` and `SiteCallOperationalDto.source_node = 12` exist; no field numbers reused. +- [ ] Central UI Audit Log, Notifications, and Site Calls pages all display a "Node" column and support filtering by it. +- [ ] All test projects green. +- [ ] Cluster comes up clean via `bash docker/deploy.sh`; CLI smoke confirms expected node names land in the central tables. +- [ ] Design docs (already committed in Task 0) match the implementation. diff --git a/docs/plans/2026-05-23-audit-source-node.md.tasks.json b/docs/plans/2026-05-23-audit-source-node.md.tasks.json new file mode 100644 index 0000000..0d6db80 --- /dev/null +++ b/docs/plans/2026-05-23-audit-source-node.md.tasks.json @@ -0,0 +1,27 @@ +{ + "planPath": "docs/plans/2026-05-23-audit-source-node.md", + "tasks": [ + {"id": 1, "subject": "Task 0: Branch + Snapshot", "status": "pending"}, + {"id": 2, "subject": "Task 1: NodeOptions.NodeName + INodeIdentityProvider", "status": "pending", "blockedBy": [1]}, + {"id": 3, "subject": "Task 2: Add SourceNode to AuditEvent record", "status": "pending", "blockedBy": [2]}, + {"id": 4, "subject": "Task 3: Add SourceNode to SiteCallOperational + SiteCall entity", "status": "pending", "blockedBy": [2]}, + {"id": 5, "subject": "Task 4: Add SourceNode to Notification entity + NotificationSubmit", "status": "pending", "blockedBy": [2]}, + {"id": 6, "subject": "Task 5: Add source_node to proto + update DTO mappers", "status": "pending", "blockedBy": [3, 4]}, + {"id": 7, "subject": "Task 6: EF migration — SourceNode on AuditLog + IX_AuditLog_Node_Occurred", "status": "pending", "blockedBy": [3]}, + {"id": 8, "subject": "Task 7: EF migration — SourceNode on Notifications", "status": "pending", "blockedBy": [5]}, + {"id": 9, "subject": "Task 8: EF migration — SourceNode on SiteCalls", "status": "pending", "blockedBy": [4]}, + {"id": 10, "subject": "Task 9: Site SQLite AuditLog — add SourceNode (idempotent upgrade)", "status": "pending", "blockedBy": [3]}, + {"id": 11, "subject": "Task 10: Site SQLite OperationTracking — add SourceNode", "status": "pending", "blockedBy": [4]}, + {"id": 12, "subject": "Task 11: Stamp SourceNode at site SqliteAuditWriter", "status": "pending", "blockedBy": [2, 10]}, + {"id": 13, "subject": "Task 12: Stamp SourceNode at CentralAuditWriter + persist via repo", "status": "pending", "blockedBy": [2, 7]}, + {"id": 14, "subject": "Task 13: Carry SourceNode through Notifications S&F handoff", "status": "pending", "blockedBy": [2, 5, 8]}, + {"id": 15, "subject": "Task 14: Carry SourceNode through cached-call telemetry → SiteCalls", "status": "pending", "blockedBy": [2, 9, 11]}, + {"id": 16, "subject": "Task 15: UI — Node column + filter on AuditLog grid", "status": "pending", "blockedBy": [7]}, + {"id": 17, "subject": "Task 16: UI — Node column + filter on Notifications grid", "status": "pending", "blockedBy": [7]}, + {"id": 18, "subject": "Task 17: UI — Node column + filter on SiteCalls grid", "status": "pending", "blockedBy": [7]}, + {"id": 19, "subject": "Task 18: Docker appsettings — NodeName on all 8 nodes", "status": "pending", "blockedBy": [2]}, + {"id": 20, "subject": "Task 19: Full build + targeted test sweep", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 19]}, + {"id": 21, "subject": "Task 20: Docker redeploy + smoke verify", "status": "pending", "blockedBy": [20]} + ], + "lastUpdated": "2026-05-23T00:00:00Z" +} diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index 7280374..8522e50 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -86,6 +86,7 @@ row per lifecycle event across all channels. | `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. | | `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`); NULL for a top-level run (inbound, tag-change / timer-triggered, un-bridged). | | `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. | +| `SourceNode` | `varchar(64)` NULL | The cluster node on which the event was emitted — `node-a` / `node-b` for site rows (qualified by `SourceSiteId`), `central-a` / `central-b` for central-originated rows. Nullable so reconciled rows from a node that has since been retired don't block ingest. | | `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). | | `SourceScript` | `varchar(128)` NULL | Script name within the instance. | | `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. | @@ -104,6 +105,7 @@ row per lifecycle event across all channels. - `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans. - `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters. +- `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` — per-node filters ("everything `central-a` did in window X", or pinning a misbehaving site node). - `IX_AuditLog_CorrelationId (CorrelationId)` — drilldown from a single operation. - `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request. - `IX_AuditLog_ParentExecution (ParentExecutionId)` — cross-execution drilldown: the downward leg of the execution-tree walk seeks on it (`ParentExecutionId = ancestor.ExecutionId`), and it backs the `parentExecutionId` filter. @@ -172,7 +174,9 @@ generalises to it with no schema change once that spawn point is threaded. A SQLite database file on each site node, alongside the Store-and-Forward buffer. Same schema as central minus `IngestedAtUtc` (irrelevant at the source), plus a `ForwardState` column with values `Pending | Forwarded | Reconciled` that -drives the telemetry loop and reconciliation pull. +drives the telemetry loop and reconciliation pull. `SourceNode` is stamped by the +writing node itself (`node-a` / `node-b`) at append time and travels with the row +through telemetry and reconciliation unchanged. **Site SQLite retention rule (hard invariant):** @@ -233,7 +237,9 @@ instead. The Notification Outbox dispatcher writes `Notification.NotifyDeliver` with `Status=Attempted` per delivery attempt and `Notification.NotifyDeliver` with `Status=Delivered`/`Parked`/`Discarded` on terminal status. Central direct-writes use the same insert-if-not-exists -semantics keyed on `EventId`. +semantics keyed on `EventId`. `SourceSiteId` is NULL on all central direct-write +rows; `SourceNode` is stamped to the local central node's role name +(`central-a` / `central-b`). ## Cached Operations — Combined Telemetry diff --git a/docs/requirements/Component-NotificationOutbox.md b/docs/requirements/Component-NotificationOutbox.md index e7cfb37..39f2713 100644 --- a/docs/requirements/Component-NotificationOutbox.md +++ b/docs/requirements/Component-NotificationOutbox.md @@ -63,6 +63,7 @@ The table is type-agnostic so it can record any notification type the system sup | `LastError` | Detail of the most recent failure. | | `ResolvedTargets` | Who the notification actually went to — snapshotted by central at delivery time, for audit. | | `SourceSiteId`, `SourceInstanceId`, `SourceScript` | Provenance. | +| `SourceNode` | The cluster node on which the notification was enqueued — `node-a` / `node-b` for site-originated rows (qualified by `SourceSiteId`). Nullable. Carried verbatim from the site through the S&F handoff. | | `SiteEnqueuedAt` | When the script called `Send()` (carried from the site). | | `CreatedAt` | When central ingested the row. | | `LastAttemptAt`, `NextAttemptAt`, `DeliveredAt` | Delivery timestamps. | diff --git a/docs/requirements/Component-SiteCallAudit.md b/docs/requirements/Component-SiteCallAudit.md index d020d43..06980f5 100644 --- a/docs/requirements/Component-SiteCallAudit.md +++ b/docs/requirements/Component-SiteCallAudit.md @@ -36,6 +36,10 @@ Lives in the central MS SQL configuration database — a sibling of the - **TrackedOperationId** — GUID, primary key. Generated site-side at call time. - **SourceSite** — site that issued the call. +- **SourceNode** — the cluster node on which the call was issued (`node-a` / + `node-b`, qualified by `SourceSite`). Nullable. Stamped site-side at submit + time and carried verbatim through the combined `CachedCallTelemetry` packet, + reconciliation pulls, and the central upsert. - **Kind** — `TrackedOperationKind` enum: `ExternalCall` or `DatabaseWrite`. - **TargetSummary** — external system + method name for an `ExternalCall`; for a `DatabaseWrite`, just the database connection name — intentionally not the SQL From 2e10cbe42d02ee3b6367ea69a31708bb886a9652 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 15:38:27 -0400 Subject: [PATCH 02/23] feat(host): add NodeName to NodeOptions + INodeIdentityProvider - NodeName: semantic role-within-cluster identifier (node-a/node-b on sites, central-a/central-b on central). Bound from ScadaLink:Node:NodeName. - INodeIdentityProvider exposes the trimmed name (null if unconfigured) so downstream audit writers can stamp the new SourceNode column. --- .../Services/INodeIdentityProvider.cs | 22 ++++++++ src/ScadaLink.Host/NodeIdentityProvider.cs | 20 ++++++++ src/ScadaLink.Host/NodeOptions.cs | 8 +++ src/ScadaLink.Host/ScadaLink.Host.csproj | 4 ++ src/ScadaLink.Host/SiteServiceRegistration.cs | 6 +++ .../NodeIdentityProviderTests.cs | 50 +++++++++++++++++++ 6 files changed, 110 insertions(+) create mode 100644 src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs create mode 100644 src/ScadaLink.Host/NodeIdentityProvider.cs create mode 100644 tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs diff --git a/src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs b/src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs new file mode 100644 index 0000000..f48fe25 --- /dev/null +++ b/src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs @@ -0,0 +1,22 @@ +namespace ScadaLink.Commons.Interfaces.Services; + +/// +/// Surfaces the local node's semantic role-within-cluster name so downstream +/// audit writers can stamp it on the SourceNode column. +/// +/// +/// Conventional values follow the pattern node-a/node-b on site +/// nodes and central-a/central-b on central nodes. The value is +/// a free-form operator-supplied label — there is no enforced format. When the +/// configuration value is missing, empty, or whitespace, implementations +/// return null so audit writers can persist NULL rather than an empty +/// string. +/// +public interface INodeIdentityProvider +{ + /// + /// The configured semantic node name, trimmed of surrounding whitespace. + /// null when unconfigured. + /// + string? NodeName { get; } +} diff --git a/src/ScadaLink.Host/NodeIdentityProvider.cs b/src/ScadaLink.Host/NodeIdentityProvider.cs new file mode 100644 index 0000000..6ea5fd9 --- /dev/null +++ b/src/ScadaLink.Host/NodeIdentityProvider.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.Host; + +/// +/// Binds to . +/// Empty or whitespace values are normalised to null; otherwise the value +/// is returned trimmed. +/// +internal sealed class NodeIdentityProvider : INodeIdentityProvider +{ + public string? NodeName { get; } + + public NodeIdentityProvider(IOptions nodeOptions) + { + var configured = nodeOptions.Value.NodeName; + NodeName = string.IsNullOrWhiteSpace(configured) ? null : configured.Trim(); + } +} diff --git a/src/ScadaLink.Host/NodeOptions.cs b/src/ScadaLink.Host/NodeOptions.cs index 3bc85fb..4088cb5 100644 --- a/src/ScadaLink.Host/NodeOptions.cs +++ b/src/ScadaLink.Host/NodeOptions.cs @@ -4,6 +4,14 @@ public class NodeOptions { public string Role { get; set; } = string.Empty; public string NodeHostname { get; set; } = string.Empty; + + /// + /// Operator-configured semantic node name used to stamp the SourceNode + /// column on audit rows. Conventional values are node-a/node-b + /// on site nodes and central-a/central-b on central nodes, + /// but the value is a free-form label — no validation is enforced. + /// + public string NodeName { get; set; } = string.Empty; public string? SiteId { get; set; } public int RemotingPort { get; set; } = 8081; public int GrpcPort { get; set; } = 8083; diff --git a/src/ScadaLink.Host/ScadaLink.Host.csproj b/src/ScadaLink.Host/ScadaLink.Host.csproj index 0f027e6..8bb5a4b 100644 --- a/src/ScadaLink.Host/ScadaLink.Host.csproj +++ b/src/ScadaLink.Host/ScadaLink.Host.csproj @@ -7,6 +7,10 @@ true + + + + diff --git a/src/ScadaLink.Host/SiteServiceRegistration.cs b/src/ScadaLink.Host/SiteServiceRegistration.cs index 0e59f9b..97ce314 100644 --- a/src/ScadaLink.Host/SiteServiceRegistration.cs +++ b/src/ScadaLink.Host/SiteServiceRegistration.cs @@ -1,6 +1,7 @@ using ScadaLink.AuditLog; using ScadaLink.ClusterInfrastructure; using ScadaLink.Communication; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.DataConnectionLayer; using ScadaLink.ExternalSystemGateway; using ScadaLink.HealthMonitoring; @@ -96,5 +97,10 @@ public static class SiteServiceRegistration services.Configure(config.GetSection("ScadaLink:HealthMonitoring")); services.Configure(config.GetSection("ScadaLink:Notification")); services.Configure(config.GetSection("ScadaLink:Logging")); + + // Audit Log (#23) — exposes ScadaLink:Node:NodeName to downstream audit + // writers so they can stamp the SourceNode column. Registered here in + // shared bootstrap because every node (central + site) needs it. + services.AddSingleton(); } } diff --git a/tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs b/tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs new file mode 100644 index 0000000..c7322a2 --- /dev/null +++ b/tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.Host.Tests; + +/// +/// Tests for NodeIdentityProvider — surfaces the operator-configured semantic +/// node name (e.g. node-a / node-b / central-a / central-b) used by downstream +/// audit writers to stamp the SourceNode column. +/// +public class NodeIdentityProviderTests +{ + private static INodeIdentityProvider BuildProvider(string nodeName) + { + var options = Options.Create(new NodeOptions { NodeName = nodeName }); + return new NodeIdentityProvider(options); + } + + [Fact] + public void NodeIdentityProvider_returns_configured_NodeName() + { + var provider = BuildProvider("central-a"); + + Assert.Equal("central-a", provider.NodeName); + } + + [Fact] + public void NodeIdentityProvider_returns_null_when_NodeName_unset() + { + var provider = BuildProvider(string.Empty); + + Assert.Null(provider.NodeName); + } + + [Fact] + public void NodeIdentityProvider_returns_null_when_NodeName_whitespace() + { + var provider = BuildProvider(" "); + + Assert.Null(provider.NodeName); + } + + [Fact] + public void NodeIdentityProvider_trims_whitespace() + { + var provider = BuildProvider(" node-a "); + + Assert.Equal("node-a", provider.NodeName); + } +} From ad625eb36d6c7b50413d94776c97fda7c09fec17 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 15:45:31 -0400 Subject: [PATCH 03/23] feat(audit): add SourceNode property to AuditEvent record --- .../Entities/Audit/AuditEvent.cs | 9 ++++++++ .../Entities/Audit/AuditEventTests.cs | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs index dcb656f..54399ef 100644 --- a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs +++ b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs @@ -43,6 +43,15 @@ public sealed record AuditEvent /// Site id where the action originated; null for central-direct events. public string? SourceSiteId { get; init; } + /// + /// The cluster node on which the event was emitted — `node-a` / `node-b` for + /// site rows (qualified by ), `central-a` / `central-b` + /// for central-originated rows. Stamped by the writing node from + /// INodeIdentityProvider; nullable so reconciled rows from a node that + /// has since been retired don't block ingest. + /// + public string? SourceNode { get; init; } + /// Instance id where the action originated, when applicable. public string? SourceInstanceId { get; init; } diff --git a/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs index 89a68fd..7327200 100644 --- a/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs +++ b/tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs @@ -116,6 +116,29 @@ public class AuditEventTests Assert.Null(evt.ForwardState); } + [Fact] + public void AuditEvent_carries_SourceNode_through_init() + { + // SourceNode identifies the cluster node that emitted the event (site + // node-a/node-b or central-a/central-b). It's an additive nullable + // init-only property — defaults to null when omitted, round-trips its + // value when set, and is preserved through `with` expressions. + var evtDefault = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Submitted, + PayloadTruncated = false + }; + Assert.Null(evtDefault.SourceNode); + + var evtStamped = evtDefault with { SourceNode = "node-a" }; + Assert.Equal("node-a", evtStamped.SourceNode); + Assert.Null(evtDefault.SourceNode); + } + [Fact] public void With_ProducesNewInstance_WithSingleFieldChanged() { From 354f8792bf4ba68702062a7e7c2f30e362389e7d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 15:46:30 -0400 Subject: [PATCH 04/23] feat(notif-outbox): add SourceNode to Notification entity + NotificationSubmit --- .../Entities/Notifications/Notification.cs | 9 +++++++ .../Notification/NotificationMessages.cs | 12 ++++++++- .../Entities/NotificationEntityTests.cs | 14 ++++++++++ .../Messages/NotificationMessagesTests.cs | 26 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs index f9305d0..7113008 100644 --- a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs +++ b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs @@ -25,6 +25,15 @@ public class Notification /// Resolved delivery targets snapshotted at delivery time, for audit. public string? ResolvedTargets { get; set; } public string SourceSiteId { get; set; } + + /// + /// The cluster node on which the notification was emitted — `node-a` / `node-b` + /// for site rows (qualified by ), `central-a` / `central-b` + /// for central-originated rows. Carried from the site on the + /// and persisted at + /// central; nullable so rows submitted before the column existed don't block ingest. + /// + public string? SourceNode { get; set; } public string? SourceInstanceId { get; set; } public string? SourceScript { get; set; } diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs index 1c5a868..2b2486c 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs @@ -18,6 +18,15 @@ namespace ScadaLink.Commons.Messages.Notification; /// NotifyDeliver audit rows. Additive trailing member — null for messages built /// before the field existed, or for non-routed runs. /// +/// +/// The cluster node on which the notification was emitted — `node-a` / `node-b` for site +/// submissions, `central-a` / `central-b` for central-originated rows. Stamped by the +/// emitting node from INodeIdentityProvider and carried, inside the serialized +/// payload, through the site store-and-forward buffer so the central dispatcher can +/// persist it on the Notifications row and echo it onto the NotifyDeliver +/// audit rows. Additive trailing member — null for messages built before the field +/// existed. +/// public record NotificationSubmit( string NotificationId, string ListName, @@ -28,7 +37,8 @@ public record NotificationSubmit( string? SourceScript, DateTimeOffset SiteEnqueuedAt, Guid? OriginExecutionId = null, - Guid? OriginParentExecutionId = null); + Guid? OriginParentExecutionId = null, + string? SourceNode = null); /// /// Central -> Site: ack sent after the notification row is persisted. diff --git a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs index bb872cd..518958f 100644 --- a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs +++ b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs @@ -51,6 +51,20 @@ public class NotificationEntityTests Assert.Equal(parentExecutionId, n.OriginParentExecutionId); } + [Fact] + public void SourceNode_DefaultsToNull_AndIsSettable() + { + // SourceNode identifies the cluster node that emitted the notification + // (site node-a/node-b or central-a/central-b). Additive nullable + // property — defaults to null on rows submitted before the column + // existed, and round-trips its value when set. + var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA"); + Assert.Null(n.SourceNode); + + n.SourceNode = "node-a"; + Assert.Equal("node-a", n.SourceNode); + } + [Fact] public void Constructor_NullArguments_Throw() { diff --git a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs index 20c090a..2b6569c 100644 --- a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs +++ b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs @@ -126,6 +126,32 @@ public class NotificationMessagesTests Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId); } + [Fact] + public void NotificationSubmit_carries_SourceNode() + { + // SourceNode is an additive trailing member — old call sites and old + // serialized payloads leave it null. When supplied it round-trips + // through both construction and JSON (the buffered S&F payload IS a + // serialized NotificationSubmit). + var defaulted = new NotificationSubmit( + "notif-9", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow); + Assert.Null(defaulted.SourceNode); + + var stamped = new NotificationSubmit( + "notif-10", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, + OriginExecutionId: null, + OriginParentExecutionId: null, + SourceNode: "node-a"); + Assert.Equal("node-a", stamped.SourceNode); + + var json = System.Text.Json.JsonSerializer.Serialize(stamped); + var roundTripped = System.Text.Json.JsonSerializer.Deserialize(json); + Assert.NotNull(roundTripped); + Assert.Equal("node-a", roundTripped!.SourceNode); + } + [Fact] public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch() { From 990eb02fe0b8b20179757e34536105b337117f71 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 15:53:44 -0400 Subject: [PATCH 05/23] feat(sitecall-audit): add SourceNode to SiteCallOperational + SiteCall entity --- .../Telemetry/CachedCallLifecycleBridge.cs | 3 ++ .../Entities/Audit/SiteCall.cs | 9 ++++ .../Types/SiteCallOperational.cs | 8 ++++ .../Scripts/ScriptRuntimeContext.cs | 12 ++++++ .../CachedCallCombinedTelemetryTests.cs | 1 + .../CachedWriteCombinedTelemetryTests.cs | 1 + .../CachedCallTelemetryForwarderTests.cs | 3 ++ .../Entities/Audit/SiteCallTests.cs | 40 ++++++++++++++++++ .../Integration/CachedCallTelemetryTests.cs | 4 ++ .../Types/SiteCallOperationalTests.cs | 42 +++++++++++++++++++ 10 files changed, 123 insertions(+) create mode 100644 tests/ScadaLink.Commons.Tests/Entities/Audit/SiteCallTests.cs create mode 100644 tests/ScadaLink.Commons.Tests/Types/SiteCallOperationalTests.cs diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs index 121370f..8ca8ba4 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs @@ -162,6 +162,9 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver Channel: context.Channel, Target: context.Target, SourceSite: context.SourceSite, + // SourceNode: stamped by Task 14 once the bridge gets an + // INodeIdentityProvider; null until then. + SourceNode: null, Status: operationalStatus, RetryCount: context.RetryCount, LastError: lastError, diff --git a/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs index f83f3ab..96c807e 100644 --- a/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs +++ b/src/ScadaLink.Commons/Entities/Audit/SiteCall.cs @@ -30,6 +30,15 @@ public sealed record SiteCall /// Site id that submitted the cached call. public required string SourceSite { get; init; } + /// + /// The cluster node on which the cached call was emitted — node-a / + /// node-b for site rows (qualified by ), + /// central-a / central-b for central-originated rows. Stamped + /// by the emitting node from INodeIdentityProvider; nullable so + /// reconciled rows from a node that has since been retired don't block ingest. + /// + public string? SourceNode { get; init; } + /// /// Lifecycle status — string form of /// . Monotonic: later rank diff --git a/src/ScadaLink.Commons/Types/SiteCallOperational.cs b/src/ScadaLink.Commons/Types/SiteCallOperational.cs index 25d9414..40acf6a 100644 --- a/src/ScadaLink.Commons/Types/SiteCallOperational.cs +++ b/src/ScadaLink.Commons/Types/SiteCallOperational.cs @@ -21,6 +21,13 @@ namespace ScadaLink.Commons.Types; /// /// Human-readable target (e.g. "ERP.GetOrder"). /// Site id that submitted the cached call. +/// +/// The cluster node on which the cached call was emitted — node-a / node-b +/// for site rows (qualified by ), central-a / +/// central-b for central-originated rows. Stamped by the emitting node from +/// INodeIdentityProvider; nullable so reconciled rows from a node that has since +/// been retired don't block ingest. +/// /// /// Lifecycle status — string form of : /// Submitted, Retrying, Attempted, Delivered, @@ -37,6 +44,7 @@ public sealed record SiteCallOperational( string Channel, string Target, string SourceSite, + string? SourceNode, string Status, int RetryCount, string? LastError, diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index a8c68fb..8f8314a 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -648,6 +648,9 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, + // SourceNode: stamped by Task 14 once the script context + // gets an INodeIdentityProvider; null until then. + SourceNode: null, Status: "Submitted", RetryCount: 0, LastError: null, @@ -766,6 +769,9 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, + // SourceNode: stamped by Task 14 once the script context + // gets an INodeIdentityProvider; null until then. + SourceNode: null, Status: "Attempted", // RetryCount stays 0 — the operation never reached the // S&F retry sweep, so no retries were performed. @@ -833,6 +839,9 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, + // SourceNode: stamped by Task 14 once the script context + // gets an INodeIdentityProvider; null until then. + SourceNode: null, Status: operationalTerminalStatus, RetryCount: 0, LastError: result.Success ? null : result.ErrorMessage, @@ -1243,6 +1252,9 @@ public class ScriptRuntimeContext Channel: "DbOutbound", Target: target, SourceSite: _siteId, + // SourceNode: stamped by Task 14 once the script context + // gets an INodeIdentityProvider; null until then. + SourceNode: null, Status: "Submitted", RetryCount: 0, LastError: null, diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs index 342b028..58e1ce0 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs @@ -72,6 +72,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture +/// Verifies the central operational entity carries the +/// SourceNode column (additive, nullable) through init-only construction and +/// with expressions. Sibling to . +/// +public class SiteCallTests +{ + private static SiteCall MinimalRow() => new() + { + TrackedOperationId = TrackedOperationId.New(), + Channel = "ApiOutbound", + Target = "ERP.GetOrder", + SourceSite = "site-01", + Status = "Submitted", + RetryCount = 0, + CreatedAtUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc), + UpdatedAtUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc), + IngestedAtUtc = new DateTime(2026, 5, 23, 12, 0, 1, DateTimeKind.Utc), + }; + + [Fact] + public void SiteCall_carries_SourceNode() + { + // SourceNode identifies the cluster node that emitted the cached call + // (site node-a/node-b or central-a/central-b). Additive nullable init + // property — defaults to null on rows ingested before the column + // existed, and round-trips its value via `with` expressions. + var row = MinimalRow(); + Assert.Null(row.SourceNode); + + var stamped = row with { SourceNode = "node-b" }; + Assert.Equal("node-b", stamped.SourceNode); + Assert.Null(row.SourceNode); + } +} diff --git a/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs b/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs index 03980d8..5c1bca7 100644 --- a/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs +++ b/tests/ScadaLink.Commons.Tests/Messages/Integration/CachedCallTelemetryTests.cs @@ -60,6 +60,10 @@ public class CachedCallTelemetryTests Channel: nameof(AuditChannel.ApiOutbound), Target: "ERP.GetOrder", SourceSite: SiteId, + // SourceNode: actual stamping arrives with Task 14; for now the + // packet builder leaves the column null so existing assertions on + // the packet's other fields stay intact. + SourceNode: null, Status: status.ToString(), RetryCount: retryCount, LastError: lastError, diff --git a/tests/ScadaLink.Commons.Tests/Types/SiteCallOperationalTests.cs b/tests/ScadaLink.Commons.Tests/Types/SiteCallOperationalTests.cs new file mode 100644 index 0000000..29d9eb4 --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Types/SiteCallOperationalTests.cs @@ -0,0 +1,42 @@ +using ScadaLink.Commons.Types; + +namespace ScadaLink.Commons.Tests.Types; + +/// +/// Verifies — the positional record carried on +/// the combined CachedCallTelemetry packet — round-trips the SourceNode +/// field through positional construction (where the parameter sits between +/// SourceSite and Status, mirroring the central SiteCalls +/// table column order). +/// +public class SiteCallOperationalTests +{ + [Fact] + public void SiteCallOperational_carries_SourceNode() + { + // SourceNode identifies the cluster node that emitted the cached call + // (site node-a/node-b or central-a/central-b). Nullable — callsites + // pass null until INodeIdentityProvider stamping arrives in Task 14. + var trackedId = TrackedOperationId.New(); + var nowUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc); + + var defaulted = new SiteCallOperational( + TrackedOperationId: trackedId, + Channel: "ApiOutbound", + Target: "ERP.GetOrder", + SourceSite: "site-01", + SourceNode: null, + Status: "Submitted", + RetryCount: 0, + LastError: null, + HttpStatus: null, + CreatedAtUtc: nowUtc, + UpdatedAtUtc: nowUtc, + TerminalAtUtc: null); + Assert.Null(defaulted.SourceNode); + + var stamped = defaulted with { SourceNode = "node-a" }; + Assert.Equal("node-a", stamped.SourceNode); + Assert.Null(defaulted.SourceNode); + } +} From dfaa416ebe8e49cace562608a12416f7a4ae2fc1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 16:10:03 -0400 Subject: [PATCH 06/23] feat(comm): add source_node field to AuditEventDto + SiteCallOperationalDto proto - AuditEventDto field 22, SiteCallOperationalDto field 12. Both follow the existing empty-string-means-null convention. - Mappers carry SourceNode end-to-end; round-trip tests cover both populated and null cases. --- .../Grpc/AuditEventDtoMapper.cs | 2 + .../Grpc/SiteCallDtoMapper.cs | 1 + .../Protos/sitestream.proto | 2 + .../SiteStreamGrpc/Sitestream.cs | 161 +++++++++++++----- .../CombinedTelemetryDispatcher.cs | 1 + .../AuditEventDtoMapperTests.cs | 53 ++++++ .../Protos/AuditEventProtoTests.cs | 2 + .../Protos/CachedTelemetryProtoTests.cs | 2 + .../SiteCallDtoMapperTests.cs | 37 ++++ 9 files changed, 221 insertions(+), 40 deletions(-) diff --git a/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs index 640cb13..a2fe2ee 100644 --- a/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs +++ b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs @@ -50,6 +50,7 @@ public static class AuditEventDtoMapper ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty, ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty, SourceSiteId = evt.SourceSiteId ?? string.Empty, + SourceNode = evt.SourceNode ?? string.Empty, SourceInstanceId = evt.SourceInstanceId ?? string.Empty, SourceScript = evt.SourceScript ?? string.Empty, Actor = evt.Actor ?? string.Empty, @@ -97,6 +98,7 @@ public static class AuditEventDtoMapper ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null, ParentExecutionId = NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null, SourceSiteId = NullIfEmpty(dto.SourceSiteId), + SourceNode = NullIfEmpty(dto.SourceNode), SourceInstanceId = NullIfEmpty(dto.SourceInstanceId), SourceScript = NullIfEmpty(dto.SourceScript), Actor = NullIfEmpty(dto.Actor), diff --git a/src/ScadaLink.Communication/Grpc/SiteCallDtoMapper.cs b/src/ScadaLink.Communication/Grpc/SiteCallDtoMapper.cs index c61e3e5..c3e7ab3 100644 --- a/src/ScadaLink.Communication/Grpc/SiteCallDtoMapper.cs +++ b/src/ScadaLink.Communication/Grpc/SiteCallDtoMapper.cs @@ -55,6 +55,7 @@ public static class SiteCallDtoMapper Channel = dto.Channel, Target = dto.Target, SourceSite = dto.SourceSite, + SourceNode = string.IsNullOrEmpty(dto.SourceNode) ? null : dto.SourceNode, Status = dto.Status, RetryCount = dto.RetryCount, LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError, diff --git a/src/ScadaLink.Communication/Protos/sitestream.proto b/src/ScadaLink.Communication/Protos/sitestream.proto index dccad81..1d8e4b2 100644 --- a/src/ScadaLink.Communication/Protos/sitestream.proto +++ b/src/ScadaLink.Communication/Protos/sitestream.proto @@ -93,6 +93,7 @@ message AuditEventDto { string extra = 19; string execution_id = 20; // empty string represents null string parent_execution_id = 21; // empty string represents null + string source_node = 22; // empty string represents null } message AuditEventBatch { repeated AuditEventDto events = 1; } @@ -114,6 +115,7 @@ message SiteCallOperationalDto { google.protobuf.Timestamp created_at_utc = 9; google.protobuf.Timestamp updated_at_utc = 10; google.protobuf.Timestamp terminal_at_utc = 11; // absent when not terminal + string source_node = 12; // empty string represents null } message CachedTelemetryPacket { diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs index 41e11e4..0732cde 100644 --- a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs +++ b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs @@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc { "c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy", "aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90", "b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs", - "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiqAQKDUF1ZGl0RXZlbnRE", + "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkivQQKDUF1ZGl0RXZlbnRE", "dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL", "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ", "EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291", @@ -53,43 +53,44 @@ namespace ScadaLink.Communication.Grpc { "bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz", "dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR", "cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl", - "Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAki", - "PAoPQXVkaXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJl", - "YW0uQXVkaXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZl", - "bnRfaWRzGAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRy", - "YWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoG", - "dGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgF", - "IAEoCRITCgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJ", - "EjAKC2h0dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMy", - "VmFsdWUSMgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9i", - "dWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xl", - "LnByb3RvYnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsy", - "Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0", - "cnlQYWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1", - "ZGl0RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFt", - "LlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0", - "Y2gSMgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1l", - "dHJ5UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2Vf", - "dXRjGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRj", - "aF9zaXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2", - "ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3Jl", - "X2F2YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVD", - "SUZJRUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJ", - "ThACEg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxB", - "Uk1fU1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQ", - "ARIWChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0S", - "FAoQQUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcK", - "E0FMQVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMS", - "GQoVQUxBUk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2", - "aWNlElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5j", - "ZVN0cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDAB", - "EkcKEUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50", - "QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRU", - "ZWxlbWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUu", - "c2l0ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0", - "ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5Q", - "dWxsQXVkaXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmlj", - "YXRpb24uR3JwY2IGcHJvdG8z")); + "Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAkS", + "EwoLc291cmNlX25vZGUYFiABKAkiPAoPQXVkaXRFdmVudEJhdGNoEikKBmV2", + "ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0byInCglJbmdl", + "c3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRzGAEgAygJIokDChZTaXRlQ2Fs", + "bE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJ", + "Eg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9z", + "aXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRITCgtyZXRyeV9jb3VudBgGIAEo", + "BRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0dHBfc3RhdHVzGAggASgLMhsu", + "Z29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSMgoOY3JlYXRlZF9hdF91dGMY", + "CSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRf", + "YXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIzCg90", + "ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0", + "YW1wEhMKC3NvdXJjZV9ub2RlGAwgASgJIoABChVDYWNoZWRUZWxlbWV0cnlQ", + "YWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0", + "RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNp", + "dGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gS", + "MgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5", + "UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRj", + "GAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9z", + "aXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50", + "cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2", + "YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJ", + "RUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThAC", + "Eg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1f", + "U1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIW", + "ChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQ", + "QUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FM", + "QVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoV", + "QUxBUk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2aWNl", + "ElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0", + "cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcK", + "EUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0", + "Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxl", + "bWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0", + "ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0", + "cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxs", + "QXVkaXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRp", + "b24uR3JwY2IGcHJvdG8z")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] { @@ -97,10 +98,10 @@ namespace ScadaLink.Communication.Grpc { new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId", "ParentExecutionId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId", "ParentExecutionId", "SourceNode" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc", "SourceNode" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.CachedTelemetryPacket), global::ScadaLink.Communication.Grpc.CachedTelemetryPacket.Parser, new[]{ "AuditEvent", "Operational" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.CachedTelemetryBatch), global::ScadaLink.Communication.Grpc.CachedTelemetryBatch.Parser, new[]{ "Packets" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.PullAuditEventsRequest), global::ScadaLink.Communication.Grpc.PullAuditEventsRequest.Parser, new[]{ "SinceUtc", "BatchSize" }, null, null, null, null), @@ -1594,6 +1595,7 @@ namespace ScadaLink.Communication.Grpc { extra_ = other.extra_; executionId_ = other.executionId_; parentExecutionId_ = other.parentExecutionId_; + sourceNode_ = other.sourceNode_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -1871,6 +1873,21 @@ namespace ScadaLink.Communication.Grpc { } } + /// Field number for the "source_node" field. + public const int SourceNodeFieldNumber = 22; + private string sourceNode_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SourceNode { + get { return sourceNode_; } + set { + sourceNode_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -1907,6 +1924,7 @@ namespace ScadaLink.Communication.Grpc { if (Extra != other.Extra) return false; if (ExecutionId != other.ExecutionId) return false; if (ParentExecutionId != other.ParentExecutionId) return false; + if (SourceNode != other.SourceNode) return false; return Equals(_unknownFields, other._unknownFields); } @@ -1935,6 +1953,7 @@ namespace ScadaLink.Communication.Grpc { if (Extra.Length != 0) hash ^= Extra.GetHashCode(); if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode(); if (ParentExecutionId.Length != 0) hash ^= ParentExecutionId.GetHashCode(); + if (SourceNode.Length != 0) hash ^= SourceNode.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -2035,6 +2054,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(170, 1); output.WriteString(ParentExecutionId); } + if (SourceNode.Length != 0) { + output.WriteRawTag(178, 1); + output.WriteString(SourceNode); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -2127,6 +2150,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(170, 1); output.WriteString(ParentExecutionId); } + if (SourceNode.Length != 0) { + output.WriteRawTag(178, 1); + output.WriteString(SourceNode); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -2200,6 +2227,9 @@ namespace ScadaLink.Communication.Grpc { if (ParentExecutionId.Length != 0) { size += 2 + pb::CodedOutputStream.ComputeStringSize(ParentExecutionId); } + if (SourceNode.Length != 0) { + size += 2 + pb::CodedOutputStream.ComputeStringSize(SourceNode); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -2282,6 +2312,9 @@ namespace ScadaLink.Communication.Grpc { if (other.ParentExecutionId.Length != 0) { ParentExecutionId = other.ParentExecutionId; } + if (other.SourceNode.Length != 0) { + SourceNode = other.SourceNode; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -2394,6 +2427,10 @@ namespace ScadaLink.Communication.Grpc { ParentExecutionId = input.ReadString(); break; } + case 178: { + SourceNode = input.ReadString(); + break; + } } } #endif @@ -2506,6 +2543,10 @@ namespace ScadaLink.Communication.Grpc { ParentExecutionId = input.ReadString(); break; } + case 178: { + SourceNode = input.ReadString(); + break; + } } } } @@ -2939,6 +2980,7 @@ namespace ScadaLink.Communication.Grpc { createdAtUtc_ = other.createdAtUtc_ != null ? other.createdAtUtc_.Clone() : null; updatedAtUtc_ = other.updatedAtUtc_ != null ? other.updatedAtUtc_.Clone() : null; terminalAtUtc_ = other.terminalAtUtc_ != null ? other.terminalAtUtc_.Clone() : null; + sourceNode_ = other.sourceNode_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -3097,6 +3139,21 @@ namespace ScadaLink.Communication.Grpc { } } + /// Field number for the "source_node" field. + public const int SourceNodeFieldNumber = 12; + private string sourceNode_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SourceNode { + get { return sourceNode_; } + set { + sourceNode_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -3123,6 +3180,7 @@ namespace ScadaLink.Communication.Grpc { if (!object.Equals(CreatedAtUtc, other.CreatedAtUtc)) return false; if (!object.Equals(UpdatedAtUtc, other.UpdatedAtUtc)) return false; if (!object.Equals(TerminalAtUtc, other.TerminalAtUtc)) return false; + if (SourceNode != other.SourceNode) return false; return Equals(_unknownFields, other._unknownFields); } @@ -3141,6 +3199,7 @@ namespace ScadaLink.Communication.Grpc { if (createdAtUtc_ != null) hash ^= CreatedAtUtc.GetHashCode(); if (updatedAtUtc_ != null) hash ^= UpdatedAtUtc.GetHashCode(); if (terminalAtUtc_ != null) hash ^= TerminalAtUtc.GetHashCode(); + if (SourceNode.Length != 0) hash ^= SourceNode.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -3202,6 +3261,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(90); output.WriteMessage(TerminalAtUtc); } + if (SourceNode.Length != 0) { + output.WriteRawTag(98); + output.WriteString(SourceNode); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -3255,6 +3318,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(90); output.WriteMessage(TerminalAtUtc); } + if (SourceNode.Length != 0) { + output.WriteRawTag(98); + output.WriteString(SourceNode); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -3298,6 +3365,9 @@ namespace ScadaLink.Communication.Grpc { if (terminalAtUtc_ != null) { size += 1 + pb::CodedOutputStream.ComputeMessageSize(TerminalAtUtc); } + if (SourceNode.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceNode); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -3354,6 +3424,9 @@ namespace ScadaLink.Communication.Grpc { } TerminalAtUtc.MergeFrom(other.TerminalAtUtc); } + if (other.SourceNode.Length != 0) { + SourceNode = other.SourceNode; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -3429,6 +3502,10 @@ namespace ScadaLink.Communication.Grpc { input.ReadMessage(TerminalAtUtc); break; } + case 98: { + SourceNode = input.ReadString(); + break; + } } } #endif @@ -3504,6 +3581,10 @@ namespace ScadaLink.Communication.Grpc { input.ReadMessage(TerminalAtUtc); break; } + case 98: { + SourceNode = input.ReadString(); + break; + } } } } diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs index 77c40aa..a18a2fc 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs @@ -100,6 +100,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder Channel = op.Channel, Target = op.Target, SourceSite = op.SourceSite, + SourceNode = op.SourceNode ?? string.Empty, Status = op.Status, RetryCount = op.RetryCount, LastError = op.LastError ?? string.Empty, diff --git a/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs b/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs index 06d1239..caf9e99 100644 --- a/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs +++ b/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs @@ -34,6 +34,7 @@ public class AuditEventDtoMapperTests ExecutionId = executionId, ParentExecutionId = parentExecutionId, SourceSiteId = "site-1", + SourceNode = "node-a", SourceInstanceId = "Pump01", SourceScript = "OnDemand", Actor = "design-key", @@ -61,6 +62,7 @@ public class AuditEventDtoMapperTests Assert.Equal(original.ExecutionId, roundTripped.ExecutionId); Assert.Equal(original.ParentExecutionId, roundTripped.ParentExecutionId); Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId); + Assert.Equal(original.SourceNode, roundTripped.SourceNode); Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId); Assert.Equal(original.SourceScript, roundTripped.SourceScript); Assert.Equal(original.Actor, roundTripped.Actor); @@ -99,6 +101,7 @@ public class AuditEventDtoMapperTests Assert.Equal(string.Empty, dto.ExecutionId); Assert.Equal(string.Empty, dto.ParentExecutionId); Assert.Equal(string.Empty, dto.SourceSiteId); + Assert.Equal(string.Empty, dto.SourceNode); Assert.Equal(string.Empty, dto.SourceInstanceId); Assert.Equal(string.Empty, dto.SourceScript); Assert.Equal(string.Empty, dto.Actor); @@ -124,6 +127,7 @@ public class AuditEventDtoMapperTests ExecutionId = string.Empty, ParentExecutionId = string.Empty, SourceSiteId = string.Empty, + SourceNode = string.Empty, SourceInstanceId = string.Empty, SourceScript = string.Empty, Actor = string.Empty, @@ -141,6 +145,7 @@ public class AuditEventDtoMapperTests Assert.Null(evt.ExecutionId); Assert.Null(evt.ParentExecutionId); Assert.Null(evt.SourceSiteId); + Assert.Null(evt.SourceNode); Assert.Null(evt.SourceInstanceId); Assert.Null(evt.SourceScript); Assert.Null(evt.Actor); @@ -232,4 +237,52 @@ public class AuditEventDtoMapperTests Assert.Equal("ApiCallCached", dto.Kind); Assert.Equal("Parked", dto.Status); } + + [Fact] + public void AuditEventDto_round_trip_preserves_SourceNode() + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + SourceNode = "node-a" + }; + + var dto = AuditEventDtoMapper.ToDto(evt); + + // Wire form: empty-string-means-null convention; populated value + // travels verbatim. + Assert.Equal("node-a", dto.SourceNode); + + var roundTripped = AuditEventDtoMapper.FromDto(dto); + + Assert.Equal("node-a", roundTripped.SourceNode); + } + + [Fact] + public void AuditEventDto_round_trip_preserves_null_SourceNode() + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + SourceNode = null + }; + + var dto = AuditEventDtoMapper.ToDto(evt); + + // ToDto collapses null → empty on the wire… + Assert.Equal(string.Empty, dto.SourceNode); + + var roundTripped = AuditEventDtoMapper.FromDto(dto); + + // …and FromDto rehydrates empty → null. + Assert.Null(roundTripped.SourceNode); + } } diff --git a/tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs b/tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs index 4cd0d48..632c8b5 100644 --- a/tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs +++ b/tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs @@ -25,6 +25,7 @@ public class AuditEventProtoTests Kind = "ApiCall", CorrelationId = Guid.NewGuid().ToString(), SourceSiteId = "site-1", + SourceNode = "node-a", SourceInstanceId = "Pump01", SourceScript = "OnDemand", Actor = "design-key", @@ -49,6 +50,7 @@ public class AuditEventProtoTests Assert.Equal(original.Kind, deserialized.Kind); Assert.Equal(original.CorrelationId, deserialized.CorrelationId); Assert.Equal(original.SourceSiteId, deserialized.SourceSiteId); + Assert.Equal(original.SourceNode, deserialized.SourceNode); Assert.Equal(original.SourceInstanceId, deserialized.SourceInstanceId); Assert.Equal(original.SourceScript, deserialized.SourceScript); Assert.Equal(original.Actor, deserialized.Actor); diff --git a/tests/ScadaLink.Communication.Tests/Protos/CachedTelemetryProtoTests.cs b/tests/ScadaLink.Communication.Tests/Protos/CachedTelemetryProtoTests.cs index 4405768..10d9e9b 100644 --- a/tests/ScadaLink.Communication.Tests/Protos/CachedTelemetryProtoTests.cs +++ b/tests/ScadaLink.Communication.Tests/Protos/CachedTelemetryProtoTests.cs @@ -39,6 +39,7 @@ public class CachedTelemetryProtoTests Channel = "ApiOutbound", Target = "ERP.GetOrder", SourceSite = "site-melbourne", + SourceNode = "node-a", Status = "Delivered", RetryCount = 3, LastError = "transient 503", @@ -55,6 +56,7 @@ public class CachedTelemetryProtoTests Assert.Equal(original.Channel, deserialized.Channel); Assert.Equal(original.Target, deserialized.Target); Assert.Equal(original.SourceSite, deserialized.SourceSite); + Assert.Equal(original.SourceNode, deserialized.SourceNode); Assert.Equal(original.Status, deserialized.Status); Assert.Equal(original.RetryCount, deserialized.RetryCount); Assert.Equal(original.LastError, deserialized.LastError); diff --git a/tests/ScadaLink.Communication.Tests/SiteCallDtoMapperTests.cs b/tests/ScadaLink.Communication.Tests/SiteCallDtoMapperTests.cs index 4de1d37..d9aa0bc 100644 --- a/tests/ScadaLink.Communication.Tests/SiteCallDtoMapperTests.cs +++ b/tests/ScadaLink.Communication.Tests/SiteCallDtoMapperTests.cs @@ -1,3 +1,4 @@ +using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using ScadaLink.Communication.Grpc; @@ -28,6 +29,7 @@ public class SiteCallDtoMapperTests Channel = "ApiOutbound", Target = "ERP.GetOrder", SourceSite = "site-melbourne", + SourceNode = "node-a", Status = "Delivered", RetryCount = 3, LastError = "transient 503", @@ -43,6 +45,7 @@ public class SiteCallDtoMapperTests Assert.Equal("ApiOutbound", entity.Channel); Assert.Equal("ERP.GetOrder", entity.Target); Assert.Equal("site-melbourne", entity.SourceSite); + Assert.Equal("node-a", entity.SourceNode); Assert.Equal("Delivered", entity.Status); Assert.Equal(3, entity.RetryCount); Assert.Equal("transient 503", entity.LastError); @@ -121,6 +124,40 @@ public class SiteCallDtoMapperTests Assert.Throws(() => SiteCallDtoMapper.FromDto(null!)); } + [Fact] + public void SiteCallOperationalDto_round_trip_preserves_SourceNode() + { + // Populated SourceNode travels verbatim across the wire and through + // the DTO→entity mapper. + var dto = NewMinimalDto(); + dto.SourceNode = "node-a"; + + var bytes = dto.ToByteArray(); + var onWire = SiteCallOperationalDto.Parser.ParseFrom(bytes); + Assert.Equal("node-a", onWire.SourceNode); + + var entity = SiteCallDtoMapper.FromDto(onWire); + + Assert.Equal("node-a", entity.SourceNode); + } + + [Fact] + public void SiteCallOperationalDto_round_trip_preserves_null_SourceNode() + { + // The DTO uses an empty-string-means-null convention on the wire; + // FromDto rehydrates that back to a true null on the entity. + var dto = NewMinimalDto(); + // SourceNode left at proto default (empty string) — semantically null. + + var bytes = dto.ToByteArray(); + var onWire = SiteCallOperationalDto.Parser.ParseFrom(bytes); + Assert.Equal(string.Empty, onWire.SourceNode); + + var entity = SiteCallDtoMapper.FromDto(onWire); + + Assert.Null(entity.SourceNode); + } + private static SiteCallOperationalDto NewMinimalDto() => new() { TrackedOperationId = Guid.NewGuid().ToString(), From 552d7832a33b36053b77d8cc874d68c44b8d38f8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 16:18:57 -0400 Subject: [PATCH 07/23] feat(db): add SourceNode column + IX_AuditLog_Node_Occurred index to AuditLog --- .../AuditLogEntityTypeConfiguration.cs | 16 + .../NotificationOutboxConfiguration.cs | 5 + .../SiteCallEntityTypeConfiguration.cs | 5 + ...23201754_AddAuditLogSourceNode.Designer.cs | 1646 +++++++++++++++++ .../20260523201754_AddAuditLogSourceNode.cs | 59 + .../ScadaLinkDbContextModelSnapshot.cs | 7 + .../Repositories/AuditLogRepository.cs | 11 +- .../AuditLogEntityTypeConfigurationTests.cs | 15 +- .../AddAuditLogSourceNodeMigrationTests.cs | 146 ++ 9 files changed, 1899 insertions(+), 11 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index fbd3f1d..56bff18 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -70,6 +70,14 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.SourceNode) + .HasColumnType("varchar(64)") + .HasMaxLength(64); + // Bounded unicode message column. builder.Property(e => e.ErrorMessage) .HasMaxLength(1024); @@ -97,6 +105,14 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration new { e.SourceNode, e.OccurredAtUtc }) + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc }) .IsDescending(false, false, true) .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs index cb6e4c2..3e559dc 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -47,6 +47,11 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration n.SourceScript).HasMaxLength(200); + // SourceNode (Audit Log #23, SourceNode-stamping): mapped in a follow-on migration + // (AddNotificationSourceNode). Ignored here for now so the AddAuditLogSourceNode + // migration only touches AuditLog. Removed in the AddNotificationSourceNode commit. + builder.Ignore(n => n.SourceNode); + // OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the // site so the dispatcher can echo it onto NotifyDeliver audit rows. No index — // it is never a query predicate on this table, only copied onto audit events. diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs index 78b7ccc..9a8202c 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs @@ -59,6 +59,11 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration s.LastError) .HasMaxLength(1024); + // SourceNode (Audit Log #23, SourceNode-stamping): mapped in a follow-on migration + // (AddSiteCallSourceNode). Ignored here for now so the AddAuditLogSourceNode + // migration only touches AuditLog. Removed in the AddSiteCallSourceNode commit. + builder.Ignore(s => s.SourceNode); + // Indexes — names locked for reconciliation/migration discoverability. // Source_Created backs "calls from this site" (Central UI Site Calls page, // filter by SourceSite, newest first). diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs new file mode 100644 index 0000000..860a383 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs @@ -0,0 +1,1646 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260523201754_AddAuditLogSourceNode")] + partial class AddAuditLogSourceNode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.cs new file mode 100644 index 0000000..c6a7fd5 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Adds the SourceNode column to the centralized AuditLog table (#23, + /// SourceNode-stamping). SourceNode identifies the cluster node that produced the + /// audit row (e.g. node-a, central-a) — ASCII-only, so varchar(64) + /// not nvarchar. NULL is valid (reconciled rows from a retired node, + /// central direct-write rows pre-this-feature). + /// + /// The change is purely additive: + /// 1. SourceNode varchar(64) NULL is added with no default, so the operation + /// is a metadata-only ALTER TABLE … ADD — it does NOT rewrite the + /// monthly-partitioned AuditLog table, and historical rows stay NULL. + /// 2. IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc) is created via raw + /// SQL so it lands on the ps_AuditLog_Month(OccurredAtUtc) partition scheme, + /// matching every other IX_AuditLog_* index. Keeping it partition-aligned + /// preserves the partition-switch purge path (see + /// AuditLogRepository.SwitchOutPartitionAsync). + /// + public partial class AddAuditLogSourceNode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SourceNode", + table: "AuditLog", + type: "varchar(64)", + maxLength: 64, + nullable: true); + + // Raw SQL so the index is created on the partition scheme — EF's + // CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc) + // clause. Mirrors IX_AuditLog_ParentExecution (aligned, unfiltered here: + // NULL SourceNode is a legitimate query target, e.g. "rows produced + // before stamping shipped" — no HasFilter on this index). + migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred +ON dbo.AuditLog (SourceNode, OccurredAtUtc) +ON ps_AuditLog_Month(OccurredAtUtc);"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Node_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX IX_AuditLog_Node_Occurred ON dbo.AuditLog;"); + + migrationBuilder.DropColumn( + name: "SourceNode", + table: "AuditLog"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index 1f5a5e3..d1fb95c 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -113,6 +113,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .IsUnicode(false) .HasColumnType("varchar(128)"); + b.Property("SourceNode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + b.Property("SourceScript") .HasMaxLength(128) .IsUnicode(false) @@ -156,6 +160,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .HasDatabaseName("IX_AuditLog_ParentExecution") .HasFilter("[ParentExecutionId] IS NOT NULL"); + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + b.HasIndex("SourceSiteId", "OccurredAtUtc") .IsDescending(false, true) .HasDatabaseName("IX_AuditLog_Site_Occurred"); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 33dad3a..5f1dfa8 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -273,13 +273,14 @@ VALUES PayloadTruncated bit NOT NULL, Extra nvarchar(max) NULL, ForwardState varchar(32) NULL, - -- ExecutionId and ParentExecutionId are last (in this ordinal order) - -- because each was added to the live AuditLog table by a later - -- ALTER TABLE ADD migration; the staging table must match the live - -- table column shape ordinal-for-ordinal or - -- ALTER TABLE ... SWITCH PARTITION fails. + -- ExecutionId, ParentExecutionId, and SourceNode are last (in this + -- ordinal order) because each was added to the live AuditLog table + -- by a later ALTER TABLE ADD migration; the staging table must + -- match the live table column shape ordinal-for-ordinal or + -- ALTER TABLE ... SWITCH PARTITION fails (msg 4904/4915). ExecutionId uniqueidentifier NULL, ParentExecutionId uniqueidentifier NULL, + SourceNode varchar(64) NULL, CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc) ) ON [PRIMARY]; diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs index 0b0fa0f..6b99270 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -74,10 +74,10 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable .Where(p => !p.IsShadowProperty()) .ToList(); - // AuditEvent record exposes 23 init-only properties (alog.md §4 plus the - // additive ExecutionId universal correlation column and its - // ParentExecutionId sibling). - Assert.Equal(23, properties.Count); + // AuditEvent record exposes 24 init-only properties (alog.md §4 plus the + // additive ExecutionId universal correlation column, its ParentExecutionId + // sibling, and the SourceNode-stamping column). + Assert.Equal(24, properties.Count); } [Fact] @@ -93,13 +93,16 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable // Five reconciliation/query indexes from alog.md §4, plus the EventId unique // index introduced alongside the composite PK (Bundle C), plus the additive - // IX_AuditLog_Execution index supporting ExecutionId lookups and the - // IX_AuditLog_ParentExecution index supporting ParentExecutionId lookups. + // IX_AuditLog_Execution index supporting ExecutionId lookups, the + // IX_AuditLog_ParentExecution index supporting ParentExecutionId lookups, + // and the IX_AuditLog_Node_Occurred composite supporting per-node queries + // (SourceNode-stamping). var expected = new[] { "IX_AuditLog_Channel_Status_Occurred", "IX_AuditLog_CorrelationId", "IX_AuditLog_Execution", + "IX_AuditLog_Node_Occurred", "IX_AuditLog_OccurredAtUtc", "IX_AuditLog_ParentExecution", "IX_AuditLog_Site_Occurred", diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs new file mode 100644 index 0000000..3d07565 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs @@ -0,0 +1,146 @@ +using Xunit; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// SourceNode-stamping (#23) integration tests for the +/// AddAuditLogSourceNode migration: applies the EF migrations to a +/// freshly-created MSSQL test database on the running infra/mssql container and +/// asserts that the central AuditLog table carries the new +/// SourceNode varchar(64) NULL column AND a partition-aligned +/// IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc) composite index. +/// +/// +/// Mirrors the AddAuditLogParentExecutionId shape: column is an additive +/// metadata-only ALTER TABLE … ADD; the new index is created via raw SQL +/// so it lives on ps_AuditLog_Month(OccurredAtUtc) like every other +/// IX_AuditLog_* index, preserving the partition-switch purge path. +/// Tests pair with Skip.IfNot(...) +/// so the runner reports them as Skipped (not Passed) when MSSQL is unreachable. +/// The fixture applies the migrations once at construction time. +/// +public class AddAuditLogSourceNodeMigrationTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public AddAuditLogSourceNodeMigrationTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + [SkippableFact] + public async Task AppliesMigration_AddsSourceNodeColumn_ToAuditLog() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var present = await ScalarAsync( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode' " + + "AND TABLE_SCHEMA = 'dbo';"); + Assert.Equal(1, present); + } + + [SkippableFact] + public async Task SourceNodeColumn_IsNullableVarchar64() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // varchar (ASCII), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.) + // and design doc fixes the column at varchar(64). Catches an EF default to + // nvarchar if the migration ever drops `unicode: false`. + var dataType = await ScalarAsync( + "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';"); + Assert.Equal("varchar", dataType); + + var maxLength = await ScalarAsync( + "SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';"); + Assert.Equal(64, maxLength); + + var isNullable = await ScalarAsync( + "SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';"); + Assert.Equal("YES", isNullable); + } + + [SkippableFact] + public async Task AppliesMigration_CreatesIxAuditLogNodeOccurredIndex() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // Locked index name from the design doc / CLAUDE.md. + var indexCount = await ScalarAsync( + "SELECT COUNT(*) FROM sys.indexes i " + + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + + "WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred';"); + Assert.Equal(1, indexCount); + } + + [SkippableFact] + public async Task IxAuditLogNodeOccurred_HasExpectedKeyColumnsInOrder() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // Key columns in order: SourceNode, OccurredAtUtc. sys.index_columns.key_ordinal + // gives the position in the index key (1-based); is_included_column = 0 means + // it's part of the key, not an INCLUDE. + var keyColumns = new List<(int Ordinal, string Name)>(); + + await using (var conn = _fixture.OpenConnection()) + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = + "SELECT ic.key_ordinal, c.name " + + "FROM sys.indexes i " + + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + + "INNER JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id " + + "INNER JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id " + + "WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred' " + + " AND ic.is_included_column = 0 " + + "ORDER BY ic.key_ordinal;"; + + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + keyColumns.Add((reader.GetByte(0), reader.GetString(1))); + } + } + + Assert.Equal(2, keyColumns.Count); + Assert.Equal("SourceNode", keyColumns[0].Name); + Assert.Equal("OccurredAtUtc", keyColumns[1].Name); + } + + [SkippableFact] + public async Task IxAuditLogNodeOccurred_LivesOnPsAuditLogMonth_PartitionScheme() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // Partition-aligned indexes are required so the AuditLog partition-switch + // purge keeps working. Every other IX_AuditLog_* index lives on + // ps_AuditLog_Month(OccurredAtUtc); the new one must too. + var schemeName = await ScalarAsync( + "SELECT ps.name FROM sys.indexes i " + + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + + "INNER JOIN sys.partition_schemes ps ON i.data_space_id = ps.data_space_id " + + "WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred';"); + + Assert.Equal("ps_AuditLog_Month", schemeName); + } + + // --- helpers ------------------------------------------------------------ + + private async Task ScalarAsync(string sql) + { + await using var conn = _fixture.OpenConnection(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(); + if (result is null || result is DBNull) + { + return default!; + } + return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; + } +} From 16b685b96b82972cb56341e854d923d9af12ec5a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 16:20:40 -0400 Subject: [PATCH 08/23] feat(db): add SourceNode column to Notifications --- .../NotificationOutboxConfiguration.cs | 12 +- ...1950_AddNotificationSourceNode.Designer.cs | 1650 +++++++++++++++++ ...0260523201950_AddNotificationSourceNode.cs | 41 + .../ScadaLinkDbContextModelSnapshot.cs | 4 + ...AddNotificationSourceNodeMigrationTests.cs | 76 + 5 files changed, 1779 insertions(+), 4 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationSourceNodeMigrationTests.cs diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs index 3e559dc..c8872a3 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -47,10 +47,14 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration n.SourceScript).HasMaxLength(200); - // SourceNode (Audit Log #23, SourceNode-stamping): mapped in a follow-on migration - // (AddNotificationSourceNode). Ignored here for now so the AddAuditLogSourceNode - // migration only touches AuditLog. Removed in the AddNotificationSourceNode commit. - builder.Ignore(n => n.SourceNode); + // SourceNode (Audit Log #23, SourceNode-stamping): node-local identifier of the + // cluster member that produced the notification (e.g. "node-a", "central-a"). + // NULL is valid for rows that pre-date this feature. ASCII — varchar(64). + // No index — KPIs are per-site on this table, not per-node; SourceNode is only + // echoed onto NotifyDeliver audit rows (#23) for cross-row correlation. + builder.Property(n => n.SourceNode) + .HasColumnType("varchar(64)") + .HasMaxLength(64); // OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the // site so the dispatcher can echo it onto NotifyDeliver audit rows. No index — diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs new file mode 100644 index 0000000..0772cf8 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs @@ -0,0 +1,1650 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260523201950_AddNotificationSourceNode")] + partial class AddNotificationSourceNode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.cs new file mode 100644 index 0000000..722329b --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Adds the SourceNode column to the central Notifications table (#21, + /// SourceNode-stamping). SourceNode identifies the cluster node that produced the + /// notification (e.g. node-a, central-a) — ASCII-only, so varchar(64) + /// not nvarchar. NULL is valid for rows that pre-date this feature. + /// + /// The change is purely additive: SourceNode varchar(64) NULL is added with no + /// default, so the operation is a metadata-only ALTER TABLE … ADD. Unlike + /// AuditLog, the Notifications table is NOT partitioned, so a plain + /// ADD is fine. No index — Notification Outbox KPIs are per-site, not per-node, + /// on this table; SourceNode is only echoed onto NotifyDeliver audit rows + /// (#23) for cross-row correlation. Historical rows stay NULL. + /// + public partial class AddNotificationSourceNode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SourceNode", + table: "Notifications", + type: "varchar(64)", + maxLength: 64, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SourceNode", + table: "Notifications"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index d1fb95c..69ac66a 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -820,6 +820,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .HasMaxLength(200) .HasColumnType("nvarchar(200)"); + b.Property("SourceNode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + b.Property("SourceScript") .HasMaxLength(200) .HasColumnType("nvarchar(200)"); diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationSourceNodeMigrationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationSourceNodeMigrationTests.cs new file mode 100644 index 0000000..9dd2b0f --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationSourceNodeMigrationTests.cs @@ -0,0 +1,76 @@ +using Xunit; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// SourceNode-stamping (#23) integration tests for the +/// AddNotificationSourceNode migration: applies the EF migrations to a +/// freshly-created MSSQL test database on the running infra/mssql container and +/// asserts that the central Notifications table carries the new +/// SourceNode varchar(64) NULL column. No index — Notification Outbox KPIs +/// are per-site, not per-node, so the column is never a query predicate on this +/// table; it's only echoed onto NotifyDeliver audit rows (#23) for cross-row +/// correlation. +/// +/// +/// Notifications is non-partitioned (operational state, not audit), so this +/// is a plain metadata-only ALTER TABLE … ADD with no index. +/// +public class AddNotificationSourceNodeMigrationTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public AddNotificationSourceNodeMigrationTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + [SkippableFact] + public async Task AppliesMigration_AddsSourceNodeColumn_ToNotifications() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var present = await ScalarAsync( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode' " + + "AND TABLE_SCHEMA = 'dbo';"); + Assert.Equal(1, present); + } + + [SkippableFact] + public async Task SourceNodeColumn_IsNullableVarchar64() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // varchar(64), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.). + var dataType = await ScalarAsync( + "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode';"); + Assert.Equal("varchar", dataType); + + var maxLength = await ScalarAsync( + "SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode';"); + Assert.Equal(64, maxLength); + + var isNullable = await ScalarAsync( + "SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode';"); + Assert.Equal("YES", isNullable); + } + + // --- helpers ------------------------------------------------------------ + + private async Task ScalarAsync(string sql) + { + await using var conn = _fixture.OpenConnection(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(); + if (result is null || result is DBNull) + { + return default!; + } + return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; + } +} From 1a77bc5f38457e353a303f61322eb4083c7230c3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 16:22:18 -0400 Subject: [PATCH 09/23] feat(db): add SourceNode column to SiteCalls --- .../SiteCallEntityTypeConfiguration.cs | 12 +- ...23202131_AddSiteCallSourceNode.Designer.cs | 1654 +++++++++++++++++ .../20260523202131_AddSiteCallSourceNode.cs | 42 + .../ScadaLinkDbContextModelSnapshot.cs | 4 + .../Repositories/SiteCallAuditRepository.cs | 2 +- .../AddSiteCallSourceNodeMigrationTests.cs | 75 + 6 files changed, 1784 insertions(+), 5 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddSiteCallSourceNodeMigrationTests.cs diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs index 9a8202c..206ad6e 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs @@ -59,10 +59,14 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration s.LastError) .HasMaxLength(1024); - // SourceNode (Audit Log #23, SourceNode-stamping): mapped in a follow-on migration - // (AddSiteCallSourceNode). Ignored here for now so the AddAuditLogSourceNode - // migration only touches AuditLog. Removed in the AddSiteCallSourceNode commit. - builder.Ignore(s => s.SourceNode); + // SourceNode (Audit Log #23, SourceNode-stamping): node-local identifier of the + // cluster member that produced the call (e.g. "node-a", "central-a"). NULL is + // valid for rows that pre-date this feature and for reconciled rows from a + // retired node. ASCII — varchar(64). No index — Site Call Audit KPIs are + // per-site, not per-node, on this table. + builder.Property(s => s.SourceNode) + .HasColumnType("varchar(64)") + .HasMaxLength(64); // Indexes — names locked for reconciliation/migration discoverability. // Source_Created backs "calls from this site" (Central UI Site Calls page, diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs new file mode 100644 index 0000000..4a5bbae --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs @@ -0,0 +1,1654 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260523202131_AddSiteCallSourceNode")] + partial class AddSiteCallSourceNode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.cs new file mode 100644 index 0000000..8db59c3 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Adds the SourceNode column to the central SiteCalls table (#22, + /// SourceNode-stamping). SourceNode identifies the cluster node that produced the + /// row (e.g. node-a, central-a) — ASCII-only, so varchar(64) not + /// nvarchar. NULL is valid for rows that pre-date this feature and for + /// reconciled rows from a retired node. + /// + /// The change is purely additive: SourceNode varchar(64) NULL is added with no + /// default, so the operation is a metadata-only ALTER TABLE … ADD. The + /// SiteCalls table is NOT partitioned (operational state, not audit), so a plain + /// ADD is fine. No index — Site Call Audit KPIs are per-site, not per-node, on + /// this table; SourceNode is operational metadata, never a query predicate here. + /// Historical rows stay NULL. + /// + public partial class AddSiteCallSourceNode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SourceNode", + table: "SiteCalls", + type: "varchar(64)", + maxLength: 64, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SourceNode", + table: "SiteCalls"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index 69ac66a..0ff9638 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -262,6 +262,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("RetryCount") .HasColumnType("int"); + b.Property("SourceNode") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + b.Property("SourceSite") .IsRequired() .HasMaxLength(64) diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index d90d0d9..cfe0478 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -173,7 +173,7 @@ WHERE TrackedOperationId = {idText} // NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff. FormattableString sql = $@" SELECT TOP ({paging.PageSize}) - TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount, + TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc FROM dbo.SiteCalls WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel}) diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddSiteCallSourceNodeMigrationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddSiteCallSourceNodeMigrationTests.cs new file mode 100644 index 0000000..0505ef5 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddSiteCallSourceNodeMigrationTests.cs @@ -0,0 +1,75 @@ +using Xunit; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// SourceNode-stamping (#23) integration tests for the +/// AddSiteCallSourceNode migration: applies the EF migrations to a +/// freshly-created MSSQL test database on the running infra/mssql container and +/// asserts that the central SiteCalls table carries the new +/// SourceNode varchar(64) NULL column. No index — Site Call Audit KPIs +/// are per-site, not per-node, on this table; SourceNode is operational +/// metadata, not a query predicate here. +/// +/// +/// SiteCalls is non-partitioned (operational state, not audit), so this +/// is a plain metadata-only ALTER TABLE … ADD with no index. +/// +public class AddSiteCallSourceNodeMigrationTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public AddSiteCallSourceNodeMigrationTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + [SkippableFact] + public async Task AppliesMigration_AddsSourceNodeColumn_ToSiteCalls() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var present = await ScalarAsync( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode' " + + "AND TABLE_SCHEMA = 'dbo';"); + Assert.Equal(1, present); + } + + [SkippableFact] + public async Task SourceNodeColumn_IsNullableVarchar64() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // varchar(64), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.). + var dataType = await ScalarAsync( + "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode';"); + Assert.Equal("varchar", dataType); + + var maxLength = await ScalarAsync( + "SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode';"); + Assert.Equal(64, maxLength); + + var isNullable = await ScalarAsync( + "SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode';"); + Assert.Equal("YES", isNullable); + } + + // --- helpers ------------------------------------------------------------ + + private async Task ScalarAsync(string sql) + { + await using var conn = _fixture.OpenConnection(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(); + if (result is null || result is DBNull) + { + return default!; + } + return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; + } +} From 8fb9eb0ce7b528e39d688ec3257155086138b572 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 16:45:19 -0400 Subject: [PATCH 10/23] chore(db): align SourceNode unicode metadata + document partition-aligned index recipe Tidies flagged by code review on the T6/T7/T8 migration bundle: - Add `.IsUnicode(false)` to the three SourceNode EF property mappings to match every other ASCII varchar column on the same entities. Physical column was already `varchar(64)` because `HasColumnType` wins, but the EF model metadata flag was inconsistent. - Add `unicode: false` to the three AddColumn calls in the migrations + their Designer snapshots so the historical snapshots match the model. - Update the model snapshot to carry IsUnicode(false) on each SourceNode entry. - Document the SELECT-list invariant on SiteCallAuditRepository.QueryAsync: EF Core's FromSqlInterpolated requires every entity-tracked column in the result set, so future SiteCall columns must extend the list too. - Amend plan Task 6 Step 2 to document the partition-aligned raw-SQL index recipe and the staging-table sync requirement. --- docs/plans/2026-05-23-audit-source-node.md | 19 ++++++++++++++----- .../AuditLogEntityTypeConfiguration.cs | 3 ++- .../NotificationOutboxConfiguration.cs | 3 ++- .../SiteCallEntityTypeConfiguration.cs | 3 ++- ...23201754_AddAuditLogSourceNode.Designer.cs | 1 + .../20260523201754_AddAuditLogSourceNode.cs | 1 + ...1950_AddNotificationSourceNode.Designer.cs | 2 ++ ...0260523201950_AddNotificationSourceNode.cs | 1 + ...23202131_AddSiteCallSourceNode.Designer.cs | 3 +++ .../20260523202131_AddSiteCallSourceNode.cs | 1 + .../ScadaLinkDbContextModelSnapshot.cs | 3 +++ .../Repositories/SiteCallAuditRepository.cs | 5 +++++ 12 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-05-23-audit-source-node.md b/docs/plans/2026-05-23-audit-source-node.md index 4c6e278..bd21313 100644 --- a/docs/plans/2026-05-23-audit-source-node.md +++ b/docs/plans/2026-05-23-audit-source-node.md @@ -469,13 +469,22 @@ migrationBuilder.AddColumn( maxLength: 64, nullable: true); -migrationBuilder.CreateIndex( - name: "IX_AuditLog_Node_Occurred", - table: "AuditLog", - columns: new[] { "SourceNode", "OccurredAtUtc" }); +// IMPORTANT: AuditLog is partitioned on ps_AuditLog_Month(OccurredAtUtc). +// `migrationBuilder.CreateIndex(...)` lands the index on [PRIMARY], which breaks +// `ALTER TABLE … SWITCH PARTITION` (the purge mechanism). Match the pattern used +// by the other `IX_AuditLog_*` indexes (see 20260520142214_AddAuditLogTable.cs +// and 20260521184044_AddAuditLogExecutionId.cs) — raw SQL with the partition +// scheme spelled out. Keep the fluent `HasIndex(...).HasDatabaseName(...)` in +// the EF configuration so the model snapshot stays in sync. +migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred +ON dbo.AuditLog (SourceNode, OccurredAtUtc) +ON ps_AuditLog_Month(OccurredAtUtc);"); ``` -`Down()` drops the index then the column. +`Down()` drops the index (`IF EXISTS DROP INDEX … ON dbo.AuditLog`, raw SQL) then the column. + +You will *also* need to extend `AuditLogRepository.SwitchOutPartitionAsync`'s staging-table CREATE to include `SourceNode varchar(64) NULL` in the final ordinal position. `SWITCH PARTITION` rejects schema mismatches between live and staging — without this, the PartitionPurge integration tests fail. **Step 3: Update EF configuration** diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index 56bff18..e092b65 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -76,7 +76,8 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.SourceNode) .HasColumnType("varchar(64)") - .HasMaxLength(64); + .HasMaxLength(64) + .IsUnicode(false); // Bounded unicode message column. builder.Property(e => e.ErrorMessage) diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs index c8872a3..6c2c693 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -54,7 +54,8 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration n.SourceNode) .HasColumnType("varchar(64)") - .HasMaxLength(64); + .HasMaxLength(64) + .IsUnicode(false); // OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the // site so the dispatcher can echo it onto NotifyDeliver audit rows. No index — diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs index 206ad6e..a0203ab 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs @@ -66,7 +66,8 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration s.SourceNode) .HasColumnType("varchar(64)") - .HasMaxLength(64); + .HasMaxLength(64) + .IsUnicode(false); // Indexes — names locked for reconciliation/migration discoverability. // Source_Created backs "calls from this site" (Central UI Site Calls page, diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs index 860a383..af73714 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs @@ -118,6 +118,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("SourceNode") .HasMaxLength(64) + .IsUnicode(false) .HasColumnType("varchar(64)"); b.Property("SourceScript") diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.cs index c6a7fd5..0499ece 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.cs @@ -30,6 +30,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations name: "SourceNode", table: "AuditLog", type: "varchar(64)", + unicode: false, maxLength: 64, nullable: true); diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs index 0772cf8..d4ea3b8 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs @@ -118,6 +118,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("SourceNode") .HasMaxLength(64) + .IsUnicode(false) .HasColumnType("varchar(64)"); b.Property("SourceScript") @@ -825,6 +826,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("SourceNode") .HasMaxLength(64) + .IsUnicode(false) .HasColumnType("varchar(64)"); b.Property("SourceScript") diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.cs index 722329b..2510ff6 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.cs @@ -26,6 +26,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations name: "SourceNode", table: "Notifications", type: "varchar(64)", + unicode: false, maxLength: 64, nullable: true); } diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs index 4a5bbae..fe3c141 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs @@ -118,6 +118,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("SourceNode") .HasMaxLength(64) + .IsUnicode(false) .HasColumnType("varchar(64)"); b.Property("SourceScript") @@ -267,6 +268,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("SourceNode") .HasMaxLength(64) + .IsUnicode(false) .HasColumnType("varchar(64)"); b.Property("SourceSite") @@ -829,6 +831,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("SourceNode") .HasMaxLength(64) + .IsUnicode(false) .HasColumnType("varchar(64)"); b.Property("SourceScript") diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.cs index 8db59c3..9dd92c2 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.cs @@ -27,6 +27,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations name: "SourceNode", table: "SiteCalls", type: "varchar(64)", + unicode: false, maxLength: 64, nullable: true); } diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index 0ff9638..d78df93 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -115,6 +115,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("SourceNode") .HasMaxLength(64) + .IsUnicode(false) .HasColumnType("varchar(64)"); b.Property("SourceScript") @@ -264,6 +265,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("SourceNode") .HasMaxLength(64) + .IsUnicode(false) .HasColumnType("varchar(64)"); b.Property("SourceSite") @@ -826,6 +828,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("SourceNode") .HasMaxLength(64) + .IsUnicode(false) .HasColumnType("varchar(64)"); b.Property("SourceScript") diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index cfe0478..e14d46a 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -171,6 +171,11 @@ WHERE TrackedOperationId = {idText} // and compose with the keyset cursor, so a StuckOnly page is honest: // never under-filled with a non-null next cursor. Mirrors how // NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff. + // + // SELECT-list maintenance: EF Core's FromSqlInterpolated requires every + // entity-tracked column to appear in the result set. Adding a new column + // to the SiteCall entity means extending the list below too — otherwise + // every read trips "The required column 'X' was not present" at runtime. FormattableString sql = $@" SELECT TOP ({paging.PageSize}) TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount, From f3cb8c079111beab434a8d4569a920902406dae8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 16:50:16 -0400 Subject: [PATCH 11/23] feat(audit): add SourceNode column to site SQLite AuditLog (idempotent upgrade) --- .../Site/SqliteAuditWriter.cs | 54 +++--- .../Site/SqliteAuditWriterSchemaTests.cs | 173 +++++++++++++++++- 2 files changed, 202 insertions(+), 25 deletions(-) diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index 49a6d86..0dc8e6a 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -100,6 +100,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable Kind TEXT NOT NULL, CorrelationId TEXT NULL, SourceSiteId TEXT NULL, + SourceNode TEXT NULL, SourceInstanceId TEXT NULL, SourceScript TEXT NULL, Actor TEXT NULL, @@ -144,6 +145,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable // so it is ALTER-ed in here. Nullable with no default — rows written // before this migration read back ParentExecutionId = null. AddColumnIfMissing("ParentExecutionId", "TEXT NULL"); + + // SourceNode stamping: same idempotent upgrade path as ExecutionId / + // ParentExecutionId above. A deployment that already ran the + // ParentExecutionId branch has an auditlog.db with the 22-column + // schema and no SourceNode column; CREATE TABLE IF NOT EXISTS cannot + // add it, so it is ALTER-ed in here. Nullable with no default — rows + // written before this migration read back SourceNode = null. + AddColumnIfMissing("SourceNode", "TEXT NULL"); } /// @@ -270,13 +279,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable cmd.CommandText = """ INSERT INTO AuditLog ( EventId, OccurredAtUtc, Channel, Kind, CorrelationId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, ExecutionId, ParentExecutionId ) VALUES ( $EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, - $SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target, + $SourceSiteId, $SourceNode, $SourceInstanceId, $SourceScript, $Actor, $Target, $Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail, $RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState, $ExecutionId, $ParentExecutionId @@ -289,6 +298,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text); var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text); var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text); + var pSourceNode = cmd.Parameters.Add("$SourceNode", SqliteType.Text); var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text); var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text); var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text); @@ -315,6 +325,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable pKind.Value = e.Kind.ToString(); pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value; pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value; + pSourceNode.Value = (object?)e.SourceNode ?? DBNull.Value; pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value; pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value; pActor.Value = (object?)e.Actor ?? DBNull.Value; @@ -386,7 +397,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable using var cmd = _connection.CreateCommand(); cmd.CommandText = """ SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, ExecutionId, ParentExecutionId @@ -435,7 +446,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable using var cmd = _connection.CreateCommand(); cmd.CommandText = """ SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, ExecutionId, ParentExecutionId @@ -522,7 +533,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable using var cmd = _connection.CreateCommand(); cmd.CommandText = """ SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, ExecutionId, ParentExecutionId @@ -688,22 +699,23 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable Kind = Enum.Parse(reader.GetString(3)), CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)), SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5), - SourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6), - SourceScript = reader.IsDBNull(7) ? null : reader.GetString(7), - Actor = reader.IsDBNull(8) ? null : reader.GetString(8), - Target = reader.IsDBNull(9) ? null : reader.GetString(9), - Status = Enum.Parse(reader.GetString(10)), - HttpStatus = reader.IsDBNull(11) ? null : reader.GetInt32(11), - DurationMs = reader.IsDBNull(12) ? null : reader.GetInt32(12), - ErrorMessage = reader.IsDBNull(13) ? null : reader.GetString(13), - ErrorDetail = reader.IsDBNull(14) ? null : reader.GetString(14), - RequestSummary = reader.IsDBNull(15) ? null : reader.GetString(15), - ResponseSummary = reader.IsDBNull(16) ? null : reader.GetString(16), - PayloadTruncated = reader.GetInt32(17) != 0, - Extra = reader.IsDBNull(18) ? null : reader.GetString(18), - ForwardState = Enum.Parse(reader.GetString(19)), - ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)), - ParentExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)), + SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6), + SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7), + SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8), + Actor = reader.IsDBNull(9) ? null : reader.GetString(9), + Target = reader.IsDBNull(10) ? null : reader.GetString(10), + Status = Enum.Parse(reader.GetString(11)), + HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12), + DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13), + ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14), + ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15), + RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16), + ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17), + PayloadTruncated = reader.GetInt32(18) != 0, + Extra = reader.IsDBNull(19) ? null : reader.GetString(19), + ForwardState = Enum.Parse(reader.GetString(20)), + ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)), + ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)), }; } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs index 8f40ebe..26263b9 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -43,9 +43,9 @@ public class SqliteAuditWriterSchemaTests } [Fact] - public void Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId() + public void Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId() { - var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId)); + var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId)); using (writer) { using var connection = OpenVerifierConnection(dataSource); @@ -59,12 +59,12 @@ public class SqliteAuditWriterSchemaTests columns.Add((reader.GetString(1), reader.GetInt32(5))); } - Assert.Equal(22, columns.Count); + Assert.Equal(23, columns.Count); var expected = new[] { "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", - "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", + "SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ForwardState", "ExecutionId", "ParentExecutionId", @@ -78,6 +78,19 @@ public class SqliteAuditWriterSchemaTests } } + [Fact] + public void Initialize_creates_AuditLog_with_SourceNode_column() + { + var (writer, dataSource) = CreateWriter(nameof(Initialize_creates_AuditLog_with_SourceNode_column)); + using (writer) + { + using var connection = OpenVerifierConnection(dataSource); + Assert.True( + ColumnExists(connection, "SourceNode"), + "Fresh AuditLog schema must include the SourceNode column."); + } + } + [Fact] public void Opens_Creates_IX_ForwardState_Occurred_Index() { @@ -377,4 +390,156 @@ public class SqliteAuditWriterSchemaTests Assert.Null(row.ParentExecutionId); } } + + // ----- SourceNode schema-upgrade regression (persistent auditlog.db) ----- // + + /// + /// The pre-SourceNode AuditLog schema — the 22-column CREATE TABLE + /// that HAS ExecutionId + ParentExecutionId but is WITHOUT + /// SourceNode. A deployment that ran the ParentExecutionId branch + /// already has an on-disk auditlog.db in exactly this shape, and + /// CREATE TABLE IF NOT EXISTS is a no-op against it. + /// + private const string OldPreSourceNodeSchema = """ + CREATE TABLE IF NOT EXISTS AuditLog ( + EventId TEXT NOT NULL, + OccurredAtUtc TEXT NOT NULL, + Channel TEXT NOT NULL, + Kind TEXT NOT NULL, + CorrelationId TEXT NULL, + SourceSiteId TEXT NULL, + SourceInstanceId TEXT NULL, + SourceScript TEXT NULL, + Actor TEXT NULL, + Target TEXT NULL, + Status TEXT NOT NULL, + HttpStatus INTEGER NULL, + DurationMs INTEGER NULL, + ErrorMessage TEXT NULL, + ErrorDetail TEXT NULL, + RequestSummary TEXT NULL, + ResponseSummary TEXT NULL, + PayloadTruncated INTEGER NOT NULL, + Extra TEXT NULL, + ForwardState TEXT NOT NULL, + ExecutionId TEXT NULL, + ParentExecutionId TEXT NULL, + PRIMARY KEY (EventId) + ); + CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred + ON AuditLog (ForwardState, OccurredAtUtc); + """; + + /// + /// Seeds a shared-cache in-memory database with the pre-SourceNode 22-column + /// schema and returns the open connection. The connection MUST stay open for + /// the lifetime of the test — a shared-cache in-memory database is dropped + /// once its last connection closes. + /// + private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource) + { + var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = OldPreSourceNodeSchema; + cmd.ExecuteNonQuery(); + return connection; + } + + [Fact] + public async Task Initialize_adds_SourceNode_to_pre_existing_schema() + { + var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared"; + + // A deployment that ran the ParentExecutionId branch: auditlog.db + // already exists with the 22-column schema and NO SourceNode column. + using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource); + Assert.True(ColumnExists(seedConnection, "ExecutionId")); + Assert.True(ColumnExists(seedConnection, "ParentExecutionId")); + Assert.False(ColumnExists(seedConnection, "SourceNode")); + + // Upgrade: a post-branch SqliteAuditWriter opens the same database. Its + // InitializeSchema must ALTER the missing SourceNode column in — the + // CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table. + await using (var writer = CreateWriterOver(dataSource)) + { + Assert.True( + ColumnExists(seedConnection, "SourceNode"), + "SqliteAuditWriter must ALTER the SourceNode column into a pre-existing AuditLog table."); + + // A WriteAsync binding $SourceNode must now succeed and round-trip; + // without the ALTER it would fail with "no such column: SourceNode" + // and — because audit writes are best-effort — silently drop the row. + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + PayloadTruncated = false, + SourceNode = "node-a", + }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Equal("node-a", row.SourceNode); + } + + // Idempotency: a second writer over the now-upgraded DB must not error + // (the probe sees SourceNode already present and skips the ALTER). + await using (var writerAgain = CreateWriterOver(dataSource)) + { + Assert.True(ColumnExists(seedConnection, "SourceNode")); + } + } + + [Fact] + public async Task WriteAsync_persists_SourceNode_field() + { + var (writer, _) = CreateWriter(nameof(WriteAsync_persists_SourceNode_field)); + await using (writer) + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + PayloadTruncated = false, + SourceNode = "node-a", + }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Equal("node-a", row.SourceNode); + } + } + + [Fact] + public async Task WriteAsync_persists_null_SourceNode() + { + var (writer, _) = CreateWriter(nameof(WriteAsync_persists_null_SourceNode)); + await using (writer) + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.Notification, + Kind = AuditKind.NotifySend, + Status = AuditStatus.Submitted, + PayloadTruncated = false, + // SourceNode left null + }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Null(row.SourceNode); + } + } } From 277882d230a9214468de76de727b640c8f5e0336 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 16:54:48 -0400 Subject: [PATCH 12/23] feat(site-runtime): add SourceNode column to OperationTracking + thread through RecordEnqueueAsync --- .../Telemetry/CachedCallTelemetryForwarder.cs | 4 + .../Interfaces/IOperationTrackingStore.cs | 1 + .../Types/TrackingStatusSnapshot.cs | 8 +- .../Tracking/OperationTrackingStore.cs | 58 +++++- .../CachedCallTelemetryForwarderTests.cs | 8 +- .../Scripts/TrackingApiTests.cs | 3 +- .../Tracking/OperationTrackingStoreTests.cs | 175 +++++++++++++++++- 7 files changed, 238 insertions(+), 19 deletions(-) diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs index c2cea5b..dd3958e 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs @@ -128,12 +128,16 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder // Enqueue — insert-if-not-exists with the operational // channel as the kind discriminator. RetryCount is fixed // at 0 by the tracking store's INSERT contract. + // sourceNode plumbed through but left null here; stamping + // is wired in a later task (Task 14) once the + // INodeIdentityProvider is threaded into the forwarder. await _trackingStore.RecordEnqueueAsync( telemetry.Operational.TrackedOperationId, telemetry.Operational.Channel, telemetry.Operational.Target, telemetry.Audit.SourceInstanceId, telemetry.Audit.SourceScript, + sourceNode: null, ct).ConfigureAwait(false); break; diff --git a/src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs b/src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs index add0a8c..b192441 100644 --- a/src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs +++ b/src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs @@ -40,6 +40,7 @@ public interface IOperationTrackingStore string? targetSummary, string? sourceInstanceId, string? sourceScript, + string? sourceNode, CancellationToken ct = default); /// diff --git a/src/ScadaLink.Commons/Types/TrackingStatusSnapshot.cs b/src/ScadaLink.Commons/Types/TrackingStatusSnapshot.cs index 22136ff..ea9b5a6 100644 --- a/src/ScadaLink.Commons/Types/TrackingStatusSnapshot.cs +++ b/src/ScadaLink.Commons/Types/TrackingStatusSnapshot.cs @@ -25,6 +25,11 @@ namespace ScadaLink.Commons.Types; /// UTC timestamp the row reached a terminal status; null while still active. /// Instance id that issued the cached call, when known. /// Script that issued the cached call, when known. +/// +/// Cluster node that submitted the cached call (e.g. "node-a" / +/// "node-b"), captured at enqueue time. Null on rows persisted before +/// the SourceNode stamping migration; stamping itself is wired in a later task. +/// public sealed record TrackingStatusSnapshot( TrackedOperationId Id, string Kind, @@ -37,4 +42,5 @@ public sealed record TrackingStatusSnapshot( DateTime UpdatedAtUtc, DateTime? TerminalAtUtc, string? SourceInstanceId, - string? SourceScript); + string? SourceScript, + string? SourceNode); diff --git a/src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs b/src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs index 8ef0d2f..9d6305f 100644 --- a/src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs +++ b/src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs @@ -70,12 +70,57 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable, UpdatedAtUtc TEXT NOT NULL, TerminalAtUtc TEXT NULL, SourceInstanceId TEXT NULL, - SourceScript TEXT NULL + SourceScript TEXT NULL, + SourceNode TEXT NULL ); CREATE INDEX IF NOT EXISTS IX_OperationTracking_Status_Updated ON OperationTracking (Status, UpdatedAtUtc); """; cmd.ExecuteNonQuery(); + + // SourceNode stamping: additively add the SourceNode column. + // CREATE TABLE IF NOT EXISTS above does NOT add columns to an + // OperationTracking table that already exists from a pre-SourceNode + // build, so a tracking.db created by an older build needs the column + // ALTER-ed in. The file is durable across restart/failover by design + // (retention window default 7 days), so without this step every + // RecordEnqueueAsync on an upgraded deployment would bind $sourceNode + // against a missing column and the write would fail. + // SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is + // probed first and the ALTER skipped when already there. The column is + // nullable with no default, so any row written before this migration + // reads back SourceNode = null (back-compat). + // + // NOTE: This is the FIRST idempotent column-upgrade in + // OperationTrackingStore — prior schema changes pre-dated any + // production rollout and relied solely on CREATE TABLE IF NOT EXISTS. + // The helper mirrors the SqliteAuditWriter precedent. + AddColumnIfMissing("SourceNode", "TEXT NULL"); + } + + /// + /// Additively adds a column to OperationTracking only when it is not + /// already present. SQLite lacks ADD COLUMN IF NOT EXISTS, so the + /// schema is probed via PRAGMA table_info first. Idempotent — safe + /// to run on every . Mirrors the + /// SqliteAuditWriter.AddColumnIfMissing precedent. + /// + private void AddColumnIfMissing(string columnName, string columnDefinition) + { + using var probe = _connection.CreateCommand(); + probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('OperationTracking') WHERE name = $name"; + probe.Parameters.AddWithValue("$name", columnName); + var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0; + if (exists) + { + return; + } + + using var alter = _connection.CreateCommand(); + // Column name + definition are caller-controlled constants, never user + // input — safe to interpolate (parameters are not permitted in DDL). + alter.CommandText = $"ALTER TABLE OperationTracking ADD COLUMN {columnName} {columnDefinition}"; + alter.ExecuteNonQuery(); } /// @@ -85,6 +130,7 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable, string? targetSummary, string? sourceInstanceId, string? sourceScript, + string? sourceNode, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(kind); @@ -104,12 +150,12 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable, TrackedOperationId, Kind, TargetSummary, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, - SourceInstanceId, SourceScript + SourceInstanceId, SourceScript, SourceNode ) VALUES ( $id, $kind, $targetSummary, $status, 0, NULL, NULL, $now, $now, NULL, - $sourceInstanceId, $sourceScript + $sourceInstanceId, $sourceScript, $sourceNode ); """; cmd.Parameters.AddWithValue("$id", id.ToString()); @@ -119,6 +165,7 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable, cmd.Parameters.AddWithValue("$now", now); cmd.Parameters.AddWithValue("$sourceInstanceId", (object?)sourceInstanceId ?? DBNull.Value); cmd.Parameters.AddWithValue("$sourceScript", (object?)sourceScript ?? DBNull.Value); + cmd.Parameters.AddWithValue("$sourceNode", (object?)sourceNode ?? DBNull.Value); cmd.ExecuteNonQuery(); } @@ -233,7 +280,7 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable, SELECT TrackedOperationId, Kind, TargetSummary, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, - SourceInstanceId, SourceScript + SourceInstanceId, SourceScript, SourceNode FROM OperationTracking WHERE TrackedOperationId = $id; """; @@ -257,7 +304,8 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable, UpdatedAtUtc: ParseUtc(reader.GetString(8)), TerminalAtUtc: reader.IsDBNull(9) ? null : ParseUtc(reader.GetString(9)), SourceInstanceId: reader.IsDBNull(10) ? null : reader.GetString(10), - SourceScript: reader.IsDBNull(11) ? null : reader.GetString(11)); + SourceScript: reader.IsDBNull(11) ? null : reader.GetString(11), + SourceNode: reader.IsDBNull(12) ? null : reader.GetString(12)); } finally { diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs index 1ec3d78..7be51a6 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs @@ -135,12 +135,14 @@ public class CachedCallTelemetryForwarderTests Arg.Any()); // Tracking row: insert-if-not-exists with kind discriminator. + // sourceNode is null until Task 14 wires the INodeIdentityProvider through. await _tracking.Received(1).RecordEnqueueAsync( _id, "ApiOutbound", "ERP.GetOrder", "inst-1", "ScriptActor:doStuff", + null, Arg.Any()); await _tracking.DidNotReceiveWithAnyArgs().RecordAttemptAsync( default, default!, default, default, default, default); @@ -166,7 +168,7 @@ public class CachedCallTelemetryForwarderTests await _tracking.Received(1).RecordAttemptAsync( _id, "Attempted", 2, "HTTP 503", 503, Arg.Any()); await _tracking.DidNotReceiveWithAnyArgs().RecordEnqueueAsync( - default, default!, default, default, default, default); + default, default!, default, default, default, default, default); await _tracking.DidNotReceiveWithAnyArgs().RecordTerminalAsync( default, default!, default, default, default); } @@ -189,7 +191,7 @@ public class CachedCallTelemetryForwarderTests await _tracking.Received(1).RecordTerminalAsync( _id, "Delivered", null, null, Arg.Any()); await _tracking.DidNotReceiveWithAnyArgs().RecordEnqueueAsync( - default, default!, default, default, default, default); + default, default!, default, default, default, default, default); await _tracking.DidNotReceiveWithAnyArgs().RecordAttemptAsync( default, default!, default, default, default, default); } @@ -213,6 +215,7 @@ public class CachedCallTelemetryForwarderTests Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); } @@ -225,6 +228,7 @@ public class CachedCallTelemetryForwarderTests Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("sqlite locked")); diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/TrackingApiTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/TrackingApiTests.cs index a0acd1c..ef8ffcb 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/TrackingApiTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/TrackingApiTests.cs @@ -51,7 +51,8 @@ public class TrackingApiTests UpdatedAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc), TerminalAtUtc: new DateTime(2026, 5, 20, 10, 2, 30, DateTimeKind.Utc), SourceInstanceId: "Plant.Pump42", - SourceScript: "ScriptActor:OnTick"); + SourceScript: "ScriptActor:OnTick", + SourceNode: null); var store = new Mock(); store diff --git a/tests/ScadaLink.SiteRuntime.Tests/Tracking/OperationTrackingStoreTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Tracking/OperationTrackingStoreTests.cs index 952d7dc..b302683 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Tracking/OperationTrackingStoreTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Tracking/OperationTrackingStoreTests.cs @@ -59,6 +59,7 @@ public class OperationTrackingStoreTests "TrackedOperationId", "Kind", "TargetSummary", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc", "SourceInstanceId", "SourceScript", + "SourceNode", }; Assert.Equal( expected.OrderBy(n => n), @@ -70,6 +71,159 @@ public class OperationTrackingStoreTests } } + [Fact] + public void Initialize_creates_OperationTracking_with_SourceNode_column() + { + var (store, dataSource) = CreateStore(nameof(Initialize_creates_OperationTracking_with_SourceNode_column)); + using (store) + { + using var connection = OpenVerifierConnection(dataSource); + Assert.True( + ColumnExists(connection, "SourceNode"), + "Fresh OperationTracking schema must include the SourceNode column."); + } + } + + /// + /// The pre-SourceNode OperationTracking schema — the 12-column + /// CREATE TABLE that has the original source-provenance columns + /// (SourceInstanceId, SourceScript) but is WITHOUT + /// SourceNode. A deployment that ran before the SourceNode + /// stamping work already has an on-disk tracking.db in exactly + /// this shape, and CREATE TABLE IF NOT EXISTS is a no-op against it. + /// + private const string OldPreSourceNodeSchema = """ + CREATE TABLE IF NOT EXISTS OperationTracking ( + TrackedOperationId TEXT NOT NULL PRIMARY KEY, + Kind TEXT NOT NULL, + TargetSummary TEXT NULL, + Status TEXT NOT NULL, + RetryCount INTEGER NOT NULL DEFAULT 0, + LastError TEXT NULL, + HttpStatus INTEGER NULL, + CreatedAtUtc TEXT NOT NULL, + UpdatedAtUtc TEXT NOT NULL, + TerminalAtUtc TEXT NULL, + SourceInstanceId TEXT NULL, + SourceScript TEXT NULL + ); + CREATE INDEX IF NOT EXISTS IX_OperationTracking_Status_Updated + ON OperationTracking (Status, UpdatedAtUtc); + """; + + private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource) + { + var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = OldPreSourceNodeSchema; + cmd.ExecuteNonQuery(); + return connection; + } + + private static bool ColumnExists(SqliteConnection connection, string columnName) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('OperationTracking') WHERE name = $name"; + cmd.Parameters.AddWithValue("$name", columnName); + return Convert.ToInt32(cmd.ExecuteScalar()) > 0; + } + + private static OperationTrackingStore CreateStoreOver(string dataSource) + { + var connectionString = $"Data Source={dataSource};Cache=Shared"; + var options = new OperationTrackingOptions { ConnectionString = connectionString }; + return new OperationTrackingStore( + Options.Create(options), + NullLogger.Instance); + } + + [Fact] + public async Task Initialize_adds_SourceNode_to_pre_existing_schema() + { + var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared"; + + // A pre-SourceNode deployment: tracking.db already exists with the + // 12-column schema and NO SourceNode column. + using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource); + Assert.True(ColumnExists(seedConnection, "SourceInstanceId")); + Assert.True(ColumnExists(seedConnection, "SourceScript")); + Assert.False(ColumnExists(seedConnection, "SourceNode")); + + // Upgrade: a post-branch OperationTrackingStore opens the same database. + // Its InitializeSchema must ALTER the missing SourceNode column in — + // the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing + // table. + await using (var store = CreateStoreOver(dataSource)) + { + Assert.True( + ColumnExists(seedConnection, "SourceNode"), + "OperationTrackingStore must ALTER the SourceNode column into a pre-existing OperationTracking table."); + + // A RecordEnqueueAsync binding $sourceNode must now succeed; without + // the ALTER it would fail with "no such column: SourceNode". + var id = TrackedOperationId.New(); + await store.RecordEnqueueAsync( + id, + kind: "ApiCallCached", + targetSummary: "ERP.GetOrder", + sourceInstanceId: "inst-1", + sourceScript: "ScriptActor:OnTick", + sourceNode: "node-a"); + + var snapshot = await store.GetStatusAsync(id); + Assert.NotNull(snapshot); + Assert.Equal("node-a", snapshot!.SourceNode); + } + + // Idempotency: a second store over the now-upgraded DB must not error + // (the probe sees SourceNode already present and skips the ALTER). + await using (var storeAgain = CreateStoreOver(dataSource)) + { + Assert.True(ColumnExists(seedConnection, "SourceNode")); + } + } + + [Fact] + public async Task RecordEnqueueAsync_persists_SourceNode() + { + var (store, _) = CreateStore(nameof(RecordEnqueueAsync_persists_SourceNode)); + await using var _store = store; + + var id = TrackedOperationId.New(); + await store.RecordEnqueueAsync( + id, + kind: nameof(AuditKind.ApiCallCached), + targetSummary: "ERP.GetOrder", + sourceInstanceId: "Plant.Pump42", + sourceScript: "ScriptActor:OnTick", + sourceNode: "node-a"); + + var snapshot = await store.GetStatusAsync(id); + Assert.NotNull(snapshot); + Assert.Equal("node-a", snapshot!.SourceNode); + } + + [Fact] + public async Task RecordEnqueueAsync_persists_null_SourceNode() + { + var (store, _) = CreateStore(nameof(RecordEnqueueAsync_persists_null_SourceNode)); + await using var _store = store; + + var id = TrackedOperationId.New(); + await store.RecordEnqueueAsync( + id, + kind: nameof(AuditKind.ApiCallCached), + targetSummary: "ERP.GetOrder", + sourceInstanceId: null, + sourceScript: null, + sourceNode: null); + + var snapshot = await store.GetStatusAsync(id); + Assert.NotNull(snapshot); + Assert.Null(snapshot!.SourceNode); + } + [Fact] public async Task RecordEnqueueAsync_InsertsSubmittedRow_WithRetryCountZero() { @@ -82,7 +236,8 @@ public class OperationTrackingStoreTests kind: nameof(AuditKind.ApiCallCached), targetSummary: "ERP.GetOrder", sourceInstanceId: "Plant.Pump42", - sourceScript: "ScriptActor:OnTick"); + sourceScript: "ScriptActor:OnTick", + sourceNode: null); var snapshot = await store.GetStatusAsync(id); Assert.NotNull(snapshot); @@ -107,8 +262,8 @@ public class OperationTrackingStoreTests await using var _store = store; var id = TrackedOperationId.New(); - await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", "Plant.Pump42", "ScriptActor:OnTick"); - await store.RecordEnqueueAsync(id, "ApiCallCached", "OtherTarget", "Other.Instance", "ScriptActor:Other"); + await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", "Plant.Pump42", "ScriptActor:OnTick", sourceNode: null); + await store.RecordEnqueueAsync(id, "ApiCallCached", "OtherTarget", "Other.Instance", "ScriptActor:Other", sourceNode: null); var snapshot = await store.GetStatusAsync(id); Assert.NotNull(snapshot); @@ -127,7 +282,7 @@ public class OperationTrackingStoreTests await using var _store = store; var id = TrackedOperationId.New(); - await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null); + await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null); await store.RecordAttemptAsync( id, @@ -155,7 +310,7 @@ public class OperationTrackingStoreTests await using var _store = store; var id = TrackedOperationId.New(); - await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null); + await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null); await store.RecordTerminalAsync( id, status: nameof(AuditStatus.Delivered), @@ -190,7 +345,7 @@ public class OperationTrackingStoreTests await using var _store = store; var id = TrackedOperationId.New(); - await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null); + await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null); var beforeTerminal = DateTime.UtcNow; await store.RecordTerminalAsync( @@ -228,7 +383,7 @@ public class OperationTrackingStoreTests await using var _store = store; var id = TrackedOperationId.New(); - await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null); + await store.RecordEnqueueAsync(id, "ApiCallCached", "ERP.GetOrder", null, null, sourceNode: null); await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 1, "first failure", 503); await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 2, "second failure", 503); await store.RecordAttemptAsync(id, nameof(AuditStatus.Attempted), 3, "third failure", 504); @@ -254,9 +409,9 @@ public class OperationTrackingStoreTests var bId = TrackedOperationId.New(); var cId = TrackedOperationId.New(); - await store.RecordEnqueueAsync(aId, "ApiCallCached", "A", null, null); - await store.RecordEnqueueAsync(bId, "ApiCallCached", "B", null, null); - await store.RecordEnqueueAsync(cId, "ApiCallCached", "C", null, null); + await store.RecordEnqueueAsync(aId, "ApiCallCached", "A", null, null, sourceNode: null); + await store.RecordEnqueueAsync(bId, "ApiCallCached", "B", null, null, sourceNode: null); + await store.RecordEnqueueAsync(cId, "ApiCallCached", "C", null, null, sourceNode: null); await store.RecordTerminalAsync(aId, nameof(AuditStatus.Delivered), null, 200); await store.RecordTerminalAsync(bId, nameof(AuditStatus.Delivered), null, 200); From 479870e40c275dcf8fa0cb892f78d7689d996876 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 17:08:21 -0400 Subject: [PATCH 13/23] feat(audit): stamp SourceNode at site SqliteAuditWriter from INodeIdentityProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caller-provided SourceNode wins (preserves reconciled rows from other nodes); otherwise the writer fills it from the local INodeIdentityProvider.NodeName. Reads from the provider on every write — singleton lifetime means zero overhead. --- .../Site/SqliteAuditWriter.cs | 14 +++- .../AddAuditLogTests.cs | 5 ++ .../DatabaseSyncEmissionEndToEndTests.cs | 2 + .../ExecutionIdCorrelationTests.cs | 2 + .../CombinedTelemetryHarness.cs | 2 + .../Integration/OutageReconciliationTests.cs | 2 + .../ParentExecutionIdCorrelationTests.cs | 2 + .../SyncCallEmissionEndToEndTests.cs | 4 +- .../Payload/FilterIntegrationTests.cs | 2 + .../SqliteAuditWriterBacklogStatsTests.cs | 4 +- .../Site/SqliteAuditWriterSchemaTests.cs | 3 + .../Site/SqliteAuditWriterWriteTests.cs | 64 ++++++++++++++++++- .../TestSupport/FakeNodeIdentityProvider.cs | 20 ++++++ .../AuditLog/SiteAuditPushFlowTests.cs | 4 +- 14 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 tests/ScadaLink.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index 0dc8e6a..3e3ed44 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -42,6 +42,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable private readonly SqliteConnection _connection; private readonly SqliteAuditWriterOptions _options; private readonly ILogger _logger; + private readonly INodeIdentityProvider _nodeIdentity; private readonly object _writeLock = new(); private readonly Channel _writeQueue; private readonly Task _writerLoop; @@ -50,13 +51,16 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable public SqliteAuditWriter( IOptions options, ILogger logger, + INodeIdentityProvider nodeIdentity, string? connectionStringOverride = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(nodeIdentity); _options = options.Value; _logger = logger; + _nodeIdentity = nodeIdentity; var connectionString = connectionStringOverride ?? $"Data Source={_options.DatabasePath};Cache=Shared"; @@ -325,7 +329,15 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable pKind.Value = e.Kind.ToString(); pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value; pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value; - pSourceNode.Value = (object?)e.SourceNode ?? DBNull.Value; + // SourceNode-stamping: caller-provided value wins (preserves + // rows reconciled in from other nodes via the same writer); + // otherwise stamp from the local INodeIdentityProvider. The + // event record itself is NOT mutated — stamping is at write + // time only. If the provider also returns null (unconfigured + // node), the row's SourceNode stays NULL — operators see + // "needs config" via the schema, not a magic fallback string. + var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName; + pSourceNode.Value = (object?)sourceNode ?? DBNull.Value; pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value; pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value; pActor.Value = (object?)e.Actor ?? DBNull.Value; diff --git a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs index 61d0031..b5c9012 100644 --- a/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs @@ -7,6 +7,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Configuration; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.HealthMonitoring; @@ -31,6 +32,10 @@ public class AddAuditLogTests var services = new ServiceCollection(); services.AddSingleton(); services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + // INodeIdentityProvider is registered by the Host's + // SiteServiceRegistration in production; AddAuditLog assumes its + // presence so SqliteAuditWriter and CentralAuditWriter can resolve. + services.AddSingleton(new FakeNodeIdentityProvider()); services.AddAuditLog(config); return services.BuildServiceProvider(); } diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs index a03bf9e..4269da3 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/DatabaseSyncEmissionEndToEndTests.cs @@ -9,6 +9,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -114,6 +115,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:auditlog-e1-{Guid.NewGuid():N}?mode=memory&cache=shared"); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs index 6ca8b77..e990385 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/ExecutionIdCorrelationTests.cs @@ -9,6 +9,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Audit; @@ -111,6 +112,7 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared"); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs index 538b269..c551758 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs @@ -10,6 +10,7 @@ using ScadaLink.Commons.Interfaces; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Integration; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Tests.Migrations; @@ -78,6 +79,7 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable ChannelCapacity = 1024, }), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:cachedcall-g-{Guid.NewGuid():N}?mode=memory&cache=shared"); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/OutageReconciliationTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/OutageReconciliationTests.cs index 57295be..69000bf 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/OutageReconciliationTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/OutageReconciliationTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -148,6 +149,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:outage-{Guid.NewGuid():N}?mode=memory&cache=shared"); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs index 1207c88..b1d08d8 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs @@ -16,6 +16,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -166,6 +167,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:auditlog-parentexec-{Guid.NewGuid():N}?mode=memory&cache=shared"); var ring = new RingBufferFallback(); diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs index 99d5a7d..d89e034 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/SyncCallEmissionEndToEndTests.cs @@ -7,6 +7,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -91,12 +92,13 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture - // The 3rd constructor argument is connectionStringOverride. A unique + // The 4th constructor argument is connectionStringOverride. A unique // shared-cache in-memory URI keeps the schema scoped to this writer // instance and torn down when the writer is disposed. new SqliteAuditWriter( InMemorySqliteOptions(), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source=file:auditlog-h-{Guid.NewGuid():N}?mode=memory&cache=shared"); private static IOptions FastTelemetryOptions() => diff --git a/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs b/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs index ca3aaab..d0c29f0 100644 --- a/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Payload/FilterIntegrationTests.cs @@ -11,6 +11,7 @@ using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Configuration; using ScadaLink.AuditLog.Payload; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -89,6 +90,7 @@ public class FilterIntegrationTests var sqliteWriter = new SqliteAuditWriter( Microsoft.Extensions.Options.Options.Create(new SqliteAuditWriterOptions { DatabasePath = dataSource }), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); await using var _disposeSqlite = sqliteWriter; diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs index 95f9570..1542b9b 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterBacklogStatsTests.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Enums; @@ -39,7 +40,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable var options = new SqliteAuditWriterOptions { DatabasePath = _dbPath }; return new SqliteAuditWriter( Options.Create(options), - NullLogger.Instance); + NullLogger.Instance, + new FakeNodeIdentityProvider()); } private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => new() diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs index 26263b9..b8a4872 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -2,6 +2,7 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Enums; @@ -31,6 +32,7 @@ public class SqliteAuditWriterSchemaTests var writer = new SqliteAuditWriter( Options.Create(options), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); return (writer, dataSource); } @@ -200,6 +202,7 @@ public class SqliteAuditWriterSchemaTests return new SqliteAuditWriter( Options.Create(options), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs index 58dc32c..38324f8 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs @@ -2,7 +2,9 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.AuditLog.Tests.Site; @@ -19,7 +21,8 @@ public class SqliteAuditWriterWriteTests { private static (SqliteAuditWriter writer, string dataSource) CreateWriter( string testName, - int? channelCapacity = null) + int? channelCapacity = null, + INodeIdentityProvider? nodeIdentity = null) { var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared"; var opts = new SqliteAuditWriterOptions { DatabasePath = dataSource }; @@ -28,9 +31,15 @@ public class SqliteAuditWriterWriteTests opts.ChannelCapacity = cap; } + // Default identity provider returns null — existing tests pre-date + // SourceNode stamping and have no expectation about it. New stamping + // tests pass a real provider via the parameter. + var identity = nodeIdentity ?? new FakeNodeIdentityProvider(); + var writer = new SqliteAuditWriter( Options.Create(opts), NullLogger.Instance, + identity, connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); return (writer, dataSource); } @@ -386,4 +395,57 @@ public class SqliteAuditWriterWriteTests var row = Assert.Single(rows); Assert.Null(row.ExecutionId); } + + // ----- SourceNode stamping (Tasks 11/12) ----- // + + [Fact] + public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone() + { + var (writer, _) = CreateWriter( + nameof(WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone), + nodeIdentity: new FakeNodeIdentityProvider("node-a")); + await using var _w = writer; + + var evt = NewEvent(); + Assert.Null(evt.SourceNode); // sanity check — fresh event has no SourceNode + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Equal("node-a", row.SourceNode); + } + + [Fact] + public async Task WriteAsync_PreservesCallerProvidedSourceNode() + { + var (writer, _) = CreateWriter( + nameof(WriteAsync_PreservesCallerProvidedSourceNode), + nodeIdentity: new FakeNodeIdentityProvider("node-a")); + await using var _w = writer; + + // Reconciled rows from another node arrive with their origin's + // SourceNode already populated; the writer must preserve it. + var evt = NewEvent() with { SourceNode = "node-z" }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Equal("node-z", row.SourceNode); + } + + [Fact] + public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull() + { + var (writer, _) = CreateWriter( + nameof(WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull), + nodeIdentity: new FakeNodeIdentityProvider(nodeName: null)); + await using var _w = writer; + + var evt = NewEvent(); + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Null(row.SourceNode); + } } diff --git a/tests/ScadaLink.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs b/tests/ScadaLink.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs new file mode 100644 index 0000000..969802b --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/TestSupport/FakeNodeIdentityProvider.cs @@ -0,0 +1,20 @@ +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.AuditLog.Tests.TestSupport; + +/// +/// Test fake for . Returns the configured +/// verbatim — including null — so tests can +/// exercise both the "stamped" and "unconfigured" branches of the SourceNode +/// stamping logic in +/// and . +/// +internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider +{ + public string? NodeName { get; } + + public FakeNodeIdentityProvider(string? nodeName = null) + { + NodeName = nodeName; + } +} diff --git a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs index be01f04..7db23d2 100644 --- a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs +++ b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs @@ -146,8 +146,10 @@ public class SiteAuditPushFlowTests : TestKit // + Pending queue). A temp file so it survives across DI scopes. var dbPath = Path.Combine(Path.GetTempPath(), $"auditpush-{Guid.NewGuid():N}.db"); var writerOptions = Options.Create(new SqliteAuditWriterOptions { DatabasePath = dbPath }); + var nodeIdentity = Substitute.For(); + nodeIdentity.NodeName.Returns((string?)null); await using var writer = new SqliteAuditWriter( - writerOptions, NullLogger.Instance); + writerOptions, NullLogger.Instance, nodeIdentity); // Real SiteCommunicationActor. RegisterCentralClient is given the relay // standing in for the central ClusterClient. From 974a36826a4812ff147be8cefeb75a8ece5cd2c2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 17:11:23 -0400 Subject: [PATCH 14/23] feat(audit): stamp SourceNode at CentralAuditWriter + persist via AuditLogRepository CentralAuditWriter injects INodeIdentityProvider and stamps the event before handing to the repository. AuditLogRepository.InsertIfNotExistsAsync now includes SourceNode in the INSERT column list. Caller-provided value wins (supports any future direct-write callsite that already has its own node id). --- .../Central/CentralAuditWriter.cs | 26 ++++++++- .../ServiceCollectionExtensions.cs | 9 ++- .../Repositories/AuditLogRepository.cs | 4 +- .../Central/CentralAuditWriterTests.cs | 56 +++++++++++++++++++ .../Repositories/AuditLogRepositoryTests.cs | 54 +++++++++++++++++- 5 files changed, 143 insertions(+), 6 deletions(-) diff --git a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs index 80bfc45..545215c 100644 --- a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs @@ -43,6 +43,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter private readonly ILogger _logger; private readonly IAuditPayloadFilter? _filter; private readonly ICentralAuditWriteFailureCounter _failureCounter; + private readonly INodeIdentityProvider? _nodeIdentity; /// /// Bundle C (M5-T6) — the central direct-write path used by the @@ -56,18 +57,27 @@ public sealed class CentralAuditWriter : ICentralAuditWriter /// throw bumps the central health surface's /// CentralAuditWriteFailures counter. Defaults to a NoOp so test /// composition roots that don't wire the counter keep their current - /// behaviour. + /// behaviour. SourceNode-stamping (Task 12) — adds the optional + /// so central-origin rows (Notification + /// Outbox dispatch, Inbound API) carry the writing central node's + /// identifier when the caller hasn't already supplied one. Optional / + /// defaulting-to-null so M4 test composition roots that don't pass a + /// provider keep working — the caller-wins discipline means an absent + /// provider simply leaves SourceNode at whatever the caller set (often + /// null, which is the legacy behaviour). /// public CentralAuditWriter( IServiceProvider services, ILogger logger, IAuditPayloadFilter? filter = null, - ICentralAuditWriteFailureCounter? failureCounter = null) + ICentralAuditWriteFailureCounter? failureCounter = null, + INodeIdentityProvider? nodeIdentity = null) { _services = services ?? throw new ArgumentNullException(nameof(services)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _filter = filter; _failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter(); + _nodeIdentity = nodeIdentity; } /// @@ -93,6 +103,18 @@ public sealed class CentralAuditWriter : ICentralAuditWriter // M4 test composition roots (no filter passed) working unchanged. var filtered = _filter?.Apply(evt) ?? evt; + // SourceNode-stamping (Task 12): caller-provided value wins + // (supports any future direct-write callsite that already has its + // own node id); otherwise stamp from the local + // INodeIdentityProvider, when one is wired. Production DI on + // central nodes always supplies the provider; legacy test + // composition roots that don't pass it leave SourceNode at + // whatever the caller set (often null), preserving back-compat. + if (filtered.SourceNode is null && _nodeIdentity?.NodeName is { } nodeName) + { + filtered = filtered with { SourceNode = nodeName }; + } + await using var scope = _services.CreateAsyncScope(); var repo = scope.ServiceProvider.GetRequiredService(); var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow }; diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index e0d9e65..7a8a775 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -183,7 +183,14 @@ public static class ServiceCollectionExtensions sp, sp.GetRequiredService>(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + // SourceNode-stamping (Task 12): wire the local node identity so + // central-origin rows (Notification Outbox dispatch, Inbound API) + // carry the writing node's identifier when the caller hasn't + // already supplied one. GetRequiredService — the production + // composition root in SiteServiceRegistration registers the + // provider as a singleton on both site and central paths. + sp.GetRequiredService())); return services; } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 5f1dfa8..2be5862 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -65,12 +65,12 @@ public class AuditLogRepository : IAuditLogRepository $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId}) INSERT INTO dbo.AuditLog (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState) VALUES ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId}, - {evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status}, + {evt.SourceSiteId}, {evt.SourceNode}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status}, {evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary}, {evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});", ct); diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs index 1a9ca3b..cb37e8e 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; using ScadaLink.AuditLog.Central; +using ScadaLink.AuditLog.Tests.TestSupport; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; @@ -124,4 +125,59 @@ public class CentralAuditWriterTests Assert.Throws( () => new CentralAuditWriter(services, null!)); } + + // ----- SourceNode stamping (Task 12) ----- // + + private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity( + INodeIdentityProvider? nodeIdentity) + { + var repo = Substitute.For(); + var services = new ServiceCollection(); + services.AddScoped(_ => repo); + var provider = services.BuildServiceProvider(); + var writer = new CentralAuditWriter( + provider, + NullLogger.Instance, + filter: null, + failureCounter: null, + nodeIdentity: nodeIdentity); + return (writer, repo); + } + + [Fact] + public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone() + { + var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a")); + + await writer.WriteAsync(NewEvent()); + + await repo.Received(1).InsertIfNotExistsAsync( + Arg.Is(e => e.SourceNode == "central-a"), + Arg.Any()); + } + + [Fact] + public async Task WriteAsync_PreservesCallerProvidedSourceNode() + { + var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a")); + var evt = NewEvent() with { SourceNode = "central-b" }; + + await writer.WriteAsync(evt); + + await repo.Received(1).InsertIfNotExistsAsync( + Arg.Is(e => e.SourceNode == "central-b"), + Arg.Any()); + } + + [Fact] + public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull() + { + var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider(nodeName: null)); + + await writer.WriteAsync(NewEvent()); + + await repo.Received(1).InsertIfNotExistsAsync( + Arg.Is(e => e.SourceNode == null), + Arg.Any()); + } } diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index 1d7e337..669ff14 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -50,6 +50,56 @@ public class AuditLogRepositoryTests : IClassFixture Assert.Equal(evt.EventId, loaded[0].EventId); } + [SkippableFact] + public async Task InsertIfNotExistsAsync_PersistsSourceNode() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var evt = NewEvent( + siteId, + occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + sourceNode: "central-a"); + await repo.InsertIfNotExistsAsync(evt); + + await using var readContext = CreateContext(); + var loaded = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Single(loaded); + Assert.Equal("central-a", loaded[0].SourceNode); + } + + [SkippableFact] + public async Task InsertIfNotExistsAsync_PersistsNullSourceNode() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Caller passes null SourceNode (e.g. an unconfigured node) — the + // column should persist as NULL, not as the empty string. + var evt = NewEvent( + siteId, + occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + sourceNode: null); + await repo.InsertIfNotExistsAsync(evt); + + await using var readContext = CreateContext(); + var loaded = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Single(loaded); + Assert.Null(loaded[0].SourceNode); + } + [SkippableFact] public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate() { @@ -962,7 +1012,8 @@ public class AuditLogRepositoryTests : IClassFixture AuditStatus status = AuditStatus.Delivered, string? errorMessage = null, Guid? executionId = null, - Guid? parentExecutionId = null) => + Guid? parentExecutionId = null, + string? sourceNode = null) => new() { EventId = Guid.NewGuid(), @@ -971,6 +1022,7 @@ public class AuditLogRepositoryTests : IClassFixture Kind = kind, Status = status, SourceSiteId = siteId, + SourceNode = sourceNode, ErrorMessage = errorMessage, ExecutionId = executionId, ParentExecutionId = parentExecutionId, From e6341580b3df4719f5bea576c5aa96293ca9502f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 17:18:45 -0400 Subject: [PATCH 15/23] test(audit): lock null-provider passthrough on CentralAuditWriter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups flagged by code review on Tasks 11/12: - Lock the back-compat contract for CentralAuditWriter's optional `nodeIdentity = null` ctor parameter with two explicit tests (`WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected` and `WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected`). The previous null-provider path was only exercised incidentally via legacy CentralAuditWriterTests setups; the new tests make the contract explicit and distinct from the "provider supplied, returns null" path. - Document why the catch-block log references `evt` rather than the post-stamp record: the three logged fields (EventId, Kind, Status) are immutable across the filter+stamp chain, so referencing either name is equivalent — but the comment warns future maintainers to switch names if they ever add a field the chain mutates (e.g. SourceNode). --- .../Central/CentralAuditWriter.cs | 7 +++++ .../Central/CentralAuditWriterTests.cs | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs index 545215c..9f07968 100644 --- a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs @@ -136,6 +136,13 @@ public sealed class CentralAuditWriter : ICentralAuditWriter // misbehaving custom counter does, swallowing here keeps the // best-effort contract intact. } + // Log the input event's identifying fields. These three (EventId, + // Kind, Status) are immutable across the filter+stamp chain — the + // `with` clones above touch only SourceNode and IngestedAtUtc — so + // referencing `evt` here is intentional and equivalent to the + // stamped record for diagnostics. If you add a field here that the + // stamp chain DOES mutate (e.g., SourceNode), reference the latest + // post-stamp record name instead, not `evt`. _logger.LogWarning( ex, "CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})", diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs index cb37e8e..3901f06 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs @@ -180,4 +180,34 @@ public class CentralAuditWriterTests Arg.Is(e => e.SourceNode == null), Arg.Any()); } + + [Fact] + public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected() + { + // Locks the back-compat contract for the optional `nodeIdentity = null` + // ctor parameter: when no provider is wired (e.g. legacy M4 test + // composition roots), the writer must not stamp — caller value passes + // through unmodified. Distinct code path from + // "provider supplied, returns null", which the test above covers. + var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null); + + await writer.WriteAsync(NewEvent() with { SourceNode = "node-z" }); + + await repo.Received(1).InsertIfNotExistsAsync( + Arg.Is(e => e.SourceNode == "node-z"), + Arg.Any()); + } + + [Fact] + public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected() + { + // Same back-compat contract for the null-caller-null-provider case. + var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null); + + await writer.WriteAsync(NewEvent()); + + await repo.Received(1).InsertIfNotExistsAsync( + Arg.Is(e => e.SourceNode == null), + Arg.Any()); + } } From d1fcab490c1069bb0f9ea83e95844c2aa552cc69 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 17:28:23 -0400 Subject: [PATCH 16/23] feat(notif-outbox): carry + persist SourceNode end-to-end via NotificationSubmit Site: inject INodeIdentityProvider where NotificationSubmit is built; stamp SourceNode = NodeName at construction. Central: NotificationOutboxActor.HandleSubmit copies submit.SourceNode onto the Notification row; the repository INSERT persists it (EF tracked-entity insert flows it through automatically; raw-SQL extension if not). After this commit, every Notifications row carries the originating site node-a/node-b in SourceNode. Existing notifications submitted pre-feature remain NULL. --- .../NotificationOutboxActor.cs | 7 ++ .../Actors/ScriptExecutionActor.cs | 16 ++++- .../Scripts/ScriptRuntimeContext.cs | 64 +++++++++++++++++-- .../NotificationOutboxActorIngestTests.cs | 45 ++++++++++++- .../Scripts/NotifyHelperTests.cs | 45 ++++++++++++- 5 files changed, 166 insertions(+), 11 deletions(-) diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index 2c3284d..4d2cc85 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -957,6 +957,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers { SourceInstanceId = msg.SourceInstanceId, SourceScript = msg.SourceScript, + // SourceNode (SourceNode-stamping Task 13): the cluster node on which the + // notification was emitted (node-a/node-b for site rows). Stamped by the + // emitting site from INodeIdentityProvider and carried, inside the + // serialized payload, through the S&F buffer to central. EF tracked-entity + // insert flows it through to the Notifications.SourceNode column. Null on + // submissions buffered before the field existed. + SourceNode = msg.SourceNode, // OriginExecutionId (Audit Log #23): the originating script execution's id, // carried from the site so the dispatcher can echo it onto NotifyDeliver rows. OriginExecutionId = msg.OriginExecutionId, diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs index c0f517c..b16fbf8 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -124,6 +124,13 @@ public class ScriptExecutionActor : ReceiveActor // to the no-emission path (the underlying S&F handoff still // happens and a TrackedOperationId is still returned). ICachedCallTelemetryForwarder? cachedForwarder = null; + // SourceNode-stamping (Tasks 13/14): the local node name + // resolved from INodeIdentityProvider — node-a/node-b on site + // hosts. Null in tests / hosts that haven't registered the + // provider, in which case NotificationSubmit.SourceNode and + // SiteCallOperational.SourceNode stay null and central + // persists the rows with SourceNode NULL. + string? sourceNode = null; if (serviceProvider != null) { @@ -136,6 +143,7 @@ public class ScriptExecutionActor : ReceiveActor auditWriter = serviceScope.ServiceProvider.GetService(); operationTrackingStore = serviceScope.ServiceProvider.GetService(); cachedForwarder = serviceScope.ServiceProvider.GetService(); + sourceNode = serviceScope.ServiceProvider.GetService()?.NodeName; } var context = new ScriptRuntimeContext( @@ -175,7 +183,13 @@ public class ScriptExecutionActor : ReceiveActor // id for an inbound-API-routed call. The routed script still // mints its own fresh ExecutionId — this records the spawner. // Null for normal (tag-change / timer) runs. - parentExecutionId: parentExecutionId); + parentExecutionId: parentExecutionId, + // SourceNode-stamping (Tasks 13/14): the local node name + // (node-a/node-b on a site) — threaded down so Notify.Send + // and the four cached-call telemetry constructors can stamp + // it onto NotificationSubmit.SourceNode and + // SiteCallOperational.SourceNode respectively. + sourceNode: sourceNode); var globals = new ScriptGlobals { diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 8f8314a..554acb1 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -71,6 +71,19 @@ public class ScriptRuntimeContext /// private readonly string _siteId; + /// + /// SourceNode-stamping (Task 13/14): the cluster node name supplied by + /// INodeIdentityProvider on the local host — node-a/node-b + /// for site nodes. Stamped onto NotificationSubmit.SourceNode by + /// and onto SiteCallOperational.SourceNode + /// by the four / + /// cached-call telemetry construction sites so central can persist it on the + /// Notifications / SiteCalls rows. Null when no provider is + /// wired (legacy hosts / tests) — the helper construction sites pass null + /// through verbatim, leaving the central row's SourceNode as NULL too. + /// + private readonly string? _sourceNode; + /// /// Notification Outbox (FU3): identifier of the script currently executing in this /// context — stamped onto NotificationSubmit.SourceScript for the central @@ -162,7 +175,8 @@ public class ScriptRuntimeContext IOperationTrackingStore? operationTrackingStore = null, ICachedCallTelemetryForwarder? cachedForwarder = null, Guid? executionId = null, - Guid? parentExecutionId = null) + Guid? parentExecutionId = null, + string? sourceNode = null) { _instanceActor = instanceActor; _self = self; @@ -181,6 +195,11 @@ public class ScriptRuntimeContext _auditWriter = auditWriter; _operationTrackingStore = operationTrackingStore; _cachedForwarder = cachedForwarder; + // SourceNode-stamping (Task 13/14): the local node name read from + // INodeIdentityProvider at the ScriptExecutionActor; null when no + // provider was wired so the downstream callsites pass null through + // verbatim — leaving central SourceNode as NULL. + _sourceNode = sourceNode; _executionId = executionId ?? Guid.NewGuid(); // Audit Log #23 (ParentExecutionId): stored verbatim — no `?? NewGuid()` // fallback. A non-routed run legitimately has no parent and stays null. @@ -335,7 +354,10 @@ public class ScriptRuntimeContext _executionId, _auditWriter, // Audit Log #23 (ParentExecutionId): the spawning execution's id, // threaded alongside _executionId. Null for non-routed runs. - _parentExecutionId); + _parentExecutionId, + // SourceNode-stamping (Task 13): the local node name (node-a/node-b), + // threaded so NotifyTarget.Send can stamp it onto NotificationSubmit. + _sourceNode); /// /// Audit Log #23 (M3): site-local tracking-status API for cached operations. @@ -1328,6 +1350,14 @@ public class ScriptRuntimeContext /// private readonly IAuditWriter? _auditWriter; + /// + /// SourceNode-stamping (Task 13): the cluster node name on which this + /// script is executing — node-a/node-b. Stamped onto + /// NotificationSubmit.SourceNode by + /// so central can persist it on the Notifications row. + /// + private readonly string? _sourceNode; + // Parameter ordering: executionId sits immediately after the ILogger, // consistent with the other audit-threaded ctors. parentExecutionId is // a trailing optional param. @@ -1341,7 +1371,8 @@ public class ScriptRuntimeContext ILogger logger, Guid executionId, IAuditWriter? auditWriter = null, - Guid? parentExecutionId = null) + Guid? parentExecutionId = null, + string? sourceNode = null) { _storeAndForward = storeAndForward; _siteCommunicationActor = siteCommunicationActor; @@ -1353,6 +1384,7 @@ public class ScriptRuntimeContext _executionId = executionId; _auditWriter = auditWriter; _parentExecutionId = parentExecutionId; + _sourceNode = sourceNode; } /// @@ -1370,7 +1402,10 @@ public class ScriptRuntimeContext _auditWriter, // Audit Log #23 (ParentExecutionId): the spawning execution's // id, threaded alongside _executionId. Null for non-routed runs. - _parentExecutionId); + _parentExecutionId, + // SourceNode-stamping (Task 13): the local node name, stamped + // onto NotificationSubmit.SourceNode in Send(). + _sourceNode); } /// @@ -1467,6 +1502,15 @@ public class ScriptRuntimeContext /// private readonly IAuditWriter? _auditWriter; + /// + /// SourceNode-stamping (Task 13): the cluster node name on which this + /// script is executing (node-a/node-b). Stamped onto the + /// NotificationSubmit.SourceNode field in so + /// the central NotificationOutboxActor can persist it on the + /// Notifications row. + /// + private readonly string? _sourceNode; + internal NotifyTarget( string listName, StoreAndForwardService? storeAndForward, @@ -1476,7 +1520,8 @@ public class ScriptRuntimeContext ILogger logger, Guid executionId, IAuditWriter? auditWriter = null, - Guid? parentExecutionId = null) + Guid? parentExecutionId = null, + string? sourceNode = null) { _listName = listName; _storeAndForward = storeAndForward; @@ -1487,6 +1532,7 @@ public class ScriptRuntimeContext _executionId = executionId; _auditWriter = auditWriter; _parentExecutionId = parentExecutionId; + _sourceNode = sourceNode; } /// @@ -1539,7 +1585,13 @@ public class ScriptRuntimeContext // for an inbound-API-routed execution, null otherwise. It rides through // the S&F buffer to central, where the dispatcher echoes it onto the // NotifyDeliver rows so the central rows carry the routed run's parent id. - OriginParentExecutionId: _parentExecutionId); + OriginParentExecutionId: _parentExecutionId, + // SourceNode-stamping (Task 13): the cluster node name on which this + // notification was emitted (node-a/node-b). Stamped from the local + // INodeIdentityProvider via ScriptExecutionActor. Rides inside the + // serialized payload through the S&F buffer to central, where + // NotificationOutboxActor persists it on the Notifications row. + SourceNode: _sourceNode); var payloadJson = JsonSerializer.Serialize(payload); diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs index 37812d9..b55be50 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs @@ -42,7 +42,8 @@ public class NotificationOutboxActorIngestTests : TestKit private static NotificationSubmit MakeSubmit( string? notificationId = null, Guid? originExecutionId = null, - Guid? originParentExecutionId = null) + Guid? originParentExecutionId = null, + string? sourceNode = null) { return new NotificationSubmit( NotificationId: notificationId ?? Guid.NewGuid().ToString(), @@ -54,7 +55,8 @@ public class NotificationOutboxActorIngestTests : TestKit SourceScript: "AlarmScript", SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero), OriginExecutionId: originExecutionId, - OriginParentExecutionId: originParentExecutionId); + OriginParentExecutionId: originParentExecutionId, + SourceNode: sourceNode); } [Fact] @@ -192,4 +194,43 @@ public class NotificationOutboxActorIngestTests : TestKit Assert.NotNull(ack.Error); Assert.Contains("database unavailable", ack.Error); } + + [Fact] + public void NotificationSubmit_CopiesSourceNode_OntoPersistedNotification() + { + // SourceNode-stamping (Task 13): the originating site's node name (node-a/node-b) + // rides on the NotificationSubmit and must be persisted on the Notification row so + // central observers (KPIs, audit drill-ins, ops dashboards) can see which node + // emitted the notification. + _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .Returns(true); + var submit = MakeSubmit(sourceNode: "node-a"); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.SourceNode == "node-a"), + Arg.Any()); + } + + [Fact] + public void NotificationSubmit_NullSourceNode_PersistsNull() + { + // Submissions from a host that didn't wire INodeIdentityProvider, or from + // pre-SourceNode-stamping clients, carry null SourceNode — the central row must + // persist NULL rather than fall back to a placeholder. + _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .Returns(true); + var submit = MakeSubmit(sourceNode: null); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.SourceNode == null), + Arg.Any()); + } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs index d4f5eca..7cd558b 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -62,7 +62,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable IActorRef siteCommunicationActor, string? sourceScript = null, Guid? executionId = null, - Guid? parentExecutionId = null) + Guid? parentExecutionId = null, + string? sourceNode = null) { return new ScriptRuntimeContext.NotifyHelper( _saf, @@ -74,7 +75,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable NullLogger.Instance, executionId ?? Guid.NewGuid(), auditWriter: null, - parentExecutionId: parentExecutionId); + parentExecutionId: parentExecutionId, + sourceNode: sourceNode); } [Fact] @@ -197,6 +199,45 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Null(payload!.OriginParentExecutionId); } + [Fact] + public async Task Send_StampsSourceNode_OnTheNotificationSubmitPayload() + { + // SourceNode-stamping (Task 13): when the helper is wired with the + // local INodeIdentityProvider's NodeName, Notify.Send must stamp it + // onto the NotificationSubmit so it rides inside the serialized S&F + // payload to central, where NotificationOutboxActor persists it on + // the Notifications row. + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, sourceNode: "node-a"); + + var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped"); + + var buffered = await _saf.GetMessageByIdAsync(notificationId); + Assert.NotNull(buffered); + var payload = JsonSerializer.Deserialize(buffered!.PayloadJson); + Assert.NotNull(payload); + Assert.Equal("node-a", payload!.SourceNode); + } + + [Fact] + public async Task Send_NoNodeIdentity_LeavesSourceNodeNull() + { + // Hosts that don't wire INodeIdentityProvider (legacy / tests) pass + // null through. The NotificationSubmit payload's SourceNode stays + // null so the central Notifications row persists NULL rather than + // falling back to a placeholder. + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, sourceNode: null); + + var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped"); + + var buffered = await _saf.GetMessageByIdAsync(notificationId); + Assert.NotNull(buffered); + var payload = JsonSerializer.Deserialize(buffered!.PayloadJson); + Assert.NotNull(payload); + Assert.Null(payload!.SourceNode); + } + [Fact] public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull() { From 06ed0aceadac6ddc56027159b1b699afa3bb9b9d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 17:41:22 -0400 Subject: [PATCH 17/23] feat(sitecall-audit): carry + persist SourceNode end-to-end via cached telemetry Site: site emitters of SiteCallOperational (ExternalSystemClient, the script-API cached call path in ScriptRuntimeContext, CachedCallLifecycleBridge) inject INodeIdentityProvider and stamp SourceNode = NodeName at construction. OperationTrackingStore call site in CachedCallTelemetryForwarder now stamps SourceNode too. Central: SiteCallAuditRepository.UpsertAsync INSERT includes SourceNode in the column list; conditional monotonic UPDATE uses COALESCE(@SourceNode, SourceNode) so later packets cannot blank a previously- stamped value. After this commit every SiteCalls row carries node-a/node-b in SourceNode (subject to monotonic preservation). --- .../ServiceCollectionExtensions.cs | 21 ++- .../Telemetry/CachedCallLifecycleBridge.cs | 23 ++- .../Telemetry/CachedCallTelemetryForwarder.cs | 21 ++- .../Repositories/SiteCallAuditRepository.cs | 27 ++- .../Scripts/ScriptRuntimeContext.cs | 71 ++++++-- .../CachedCallLifecycleBridgeTests.cs | 64 +++++++ .../CachedCallTelemetryForwarderTests.cs | 57 ++++++- .../SiteCallAuditRepositoryTests.cs | 159 +++++++++++++++++- .../SiteCallAuditActorTests.cs | 65 ++++++- .../ExternalSystemCachedCallEmissionTests.cs | 65 +++++++ 10 files changed, 539 insertions(+), 34 deletions(-) diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index 7a8a775..a62e02f 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -146,7 +146,14 @@ public static class ServiceCollectionExtensions new CachedCallTelemetryForwarder( sp.GetRequiredService(), sp.GetService(), - sp.GetRequiredService>())); + sp.GetRequiredService>(), + // SourceNode-stamping (Task 14): the local node identity is + // threaded through so RecordEnqueueAsync can stamp the + // tracking row's SourceNode column. GetService — central + // composition roots may not register the provider, in which + // case the forwarder degrades to a null SourceNode rather + // than failing the DI resolution. + sp.GetService())); // M3 Bundle F: bridge the store-and-forward retry-loop observer hook // to the cached-call forwarder so per-attempt + terminal telemetry @@ -154,7 +161,17 @@ public static class ServiceCollectionExtensions // as the script-thread CachedSubmit row. Registered as a singleton // and also bound to ICachedCallLifecycleObserver so AddStoreAndForward // can resolve it through DI (Bundle F StoreAndForward wiring change). - services.AddSingleton(); + // SourceNode-stamping (Task 14): factory-resolved so the + // INodeIdentityProvider singleton can be threaded through — the + // bridge stamps SiteCallOperational.SourceNode from + // INodeIdentityProvider.NodeName on every cached-call lifecycle row. + // GetService (not GetRequiredService) — central composition roots may + // not register the provider, in which case the bridge degrades to a + // null SourceNode rather than failing the DI resolution. + services.AddSingleton(sp => new CachedCallLifecycleBridge( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetService())); services.AddSingleton( sp => sp.GetRequiredService()); diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs index 8ca8ba4..299f7ce 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs @@ -39,12 +39,23 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver private readonly ICachedCallTelemetryForwarder _forwarder; private readonly ILogger _logger; + /// + /// SourceNode-stamping (Task 14): the local node identity provider used to + /// stamp SiteCallOperational.SourceNode on every cached-call + /// lifecycle row this bridge emits. Optional — when null (legacy hosts / + /// tests that don't register the provider) SourceNode stays null and + /// central persists the SiteCalls row with SourceNode NULL. + /// + private readonly INodeIdentityProvider? _nodeIdentity; + public CachedCallLifecycleBridge( ICachedCallTelemetryForwarder forwarder, - ILogger logger) + ILogger logger, + INodeIdentityProvider? nodeIdentity = null) { _forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _nodeIdentity = nodeIdentity; } /// @@ -114,7 +125,7 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false); } - private static CachedCallTelemetry BuildPacket( + private CachedCallTelemetry BuildPacket( CachedCallAttemptContext context, AuditKind kind, AuditStatus status, @@ -162,9 +173,11 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver Channel: context.Channel, Target: context.Target, SourceSite: context.SourceSite, - // SourceNode: stamped by Task 14 once the bridge gets an - // INodeIdentityProvider; null until then. - SourceNode: null, + // SourceNode-stamping (Task 14): the local cluster node name + // (node-a/node-b on a site). Stamped from the injected + // INodeIdentityProvider; null when no provider was wired so + // central persists SiteCalls.SourceNode as NULL. + SourceNode: _nodeIdentity?.NodeName, Status: operationalStatus, RetryCount: context.RetryCount, LastError: lastError, diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs index dd3958e..897f047 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs @@ -53,6 +53,14 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder private readonly IOperationTrackingStore? _trackingStore; private readonly ILogger _logger; + /// + /// SourceNode-stamping (Task 14): local node identity provider used to + /// stamp the tracking-store row's SourceNode column on + /// RecordEnqueueAsync. Optional — when null (legacy / test hosts) + /// the column stays NULL on the tracking row. + /// + private readonly INodeIdentityProvider? _nodeIdentity; + /// /// Construct the forwarder. is optional — /// when null only the audit half of the packet is emitted, which matches @@ -65,11 +73,13 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder public CachedCallTelemetryForwarder( IAuditWriter auditWriter, IOperationTrackingStore? trackingStore, - ILogger logger) + ILogger logger, + INodeIdentityProvider? nodeIdentity = null) { _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _trackingStore = trackingStore; + _nodeIdentity = nodeIdentity; } /// @@ -128,16 +138,17 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder // Enqueue — insert-if-not-exists with the operational // channel as the kind discriminator. RetryCount is fixed // at 0 by the tracking store's INSERT contract. - // sourceNode plumbed through but left null here; stamping - // is wired in a later task (Task 14) once the - // INodeIdentityProvider is threaded into the forwarder. + // SourceNode-stamping (Task 14): stamp the local node + // name (node-a/node-b) from the injected + // INodeIdentityProvider; null when no provider was wired + // so the tracking row's SourceNode column stays NULL. await _trackingStore.RecordEnqueueAsync( telemetry.Operational.TrackedOperationId, telemetry.Operational.Channel, telemetry.Operational.Target, telemetry.Audit.SourceInstanceId, telemetry.Audit.SourceScript, - sourceNode: null, + sourceNode: _nodeIdentity?.NodeName, ct).ConfigureAwait(false); break; diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index e14d46a..a06282f 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -69,15 +69,20 @@ public class SiteCallAuditRepository : ISiteCallAuditRepository // Step 1: insert-if-not-exists. Like AuditLogRepository.InsertIfNotExistsAsync // this is check-then-act so a duplicate-key violation may surface under // concurrent inserts on the same id — caught + logged at Debug. + // + // SourceNode-stamping (Task 14): the column is included in the INSERT + // column list / VALUES so a fresh row carries the originating node + // name (node-a/node-b for site rows). A null SourceNode (legacy hosts + // / unstamped reconciled rows) writes NULL straight through. try { await _context.Database.ExecuteSqlInterpolatedAsync( $@"IF NOT EXISTS (SELECT 1 FROM dbo.SiteCalls WHERE TrackedOperationId = {idText}) INSERT INTO dbo.SiteCalls - (TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount, + (TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount, LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc) VALUES - ({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.Status}, {siteCall.RetryCount}, + ({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.SourceNode}, {siteCall.Status}, {siteCall.RetryCount}, {siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});", ct); } @@ -96,6 +101,21 @@ VALUES // string to the same rank table the caller uses; we only mutate if the // incoming rank is strictly greater. Same-rank (including // terminal-over-terminal) is a no-op — first-write-wins at each rank. + // + // SourceNode-stamping (Task 14): SourceNode is updated via + // COALESCE(@SourceNode, SourceNode). The operator returns @SourceNode + // when it is non-null, otherwise the stored value — so the column + // behaves protectively: a later packet that carries a null + // SourceNode (e.g. a reconciliation pull from an unstamped node) + // NEVER blanks out a value the first stamping packet set. A later + // packet that DOES carry a non-null SourceNode replaces the previous + // value — combined with the monotonic-rank guard this is + // "last-non-null-wins on rank advance", which lets a missing + // SourceNode be filled in later if Submit happened to be unstamped + // and an Attempt/Resolve carries the node identity. Within one + // lifecycle every packet should carry the same SourceNode value (one + // execution, one node) so the "overwrite" path is in practice + // idempotent. await _context.Database.ExecuteSqlInterpolatedAsync( $@"UPDATE dbo.SiteCalls SET Status = {siteCall.Status}, @@ -104,7 +124,8 @@ SET Status = {siteCall.Status}, HttpStatus = {siteCall.HttpStatus}, UpdatedAtUtc = {siteCall.UpdatedAtUtc}, TerminalAtUtc = {siteCall.TerminalAtUtc}, - IngestedAtUtc = {siteCall.IngestedAtUtc} + IngestedAtUtc = {siteCall.IngestedAtUtc}, + SourceNode = COALESCE({siteCall.SourceNode}, SourceNode) WHERE TrackedOperationId = {idText} AND {incomingRank} > (CASE Status WHEN 'Submitted' THEN 0 diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 554acb1..55e781f 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -310,7 +310,11 @@ public class ScriptRuntimeContext _cachedForwarder, // Audit Log #23 (ParentExecutionId): the spawning execution's id, // threaded alongside _executionId. Null for non-routed runs. - _parentExecutionId); + _parentExecutionId, + // SourceNode-stamping (Task 14): the local node name (node-a/node-b), + // threaded so the cached-call telemetry construction sites can stamp + // it onto SiteCallOperational.SourceNode. + _sourceNode); /// /// WP-13: Provides access to database operations. @@ -334,7 +338,11 @@ public class ScriptRuntimeContext _cachedForwarder, // Audit Log #23 (ParentExecutionId): the spawning execution's id, // threaded alongside _executionId. Null for non-routed runs. - _parentExecutionId); + _parentExecutionId, + // SourceNode-stamping (Task 14): the local node name (node-a/node-b), + // threaded so Database.CachedWrite's CachedSubmit telemetry can + // stamp it onto SiteCallOperational.SourceNode. + _sourceNode); /// /// Provides access to the Notification Outbox API. @@ -453,6 +461,16 @@ public class ScriptRuntimeContext private readonly string? _sourceScript; private readonly ICachedCallTelemetryForwarder? _cachedForwarder; + /// + /// SourceNode-stamping (Task 14): the local cluster node name on + /// which this script is executing (node-a/node-b). + /// Stamped onto SiteCallOperational.SourceNode on the three + /// cached-call telemetry construction sites (CachedSubmit + the two + /// immediate-completion rows) so central can persist it on the + /// SiteCalls row. + /// + private readonly string? _sourceNode; + // Internal constructor for tests living in ScadaLink.SiteRuntime.Tests // (via InternalsVisibleTo). Production sites resolve the helper through // ScriptRuntimeContext.ExternalSystem. @@ -474,7 +492,8 @@ public class ScriptRuntimeContext string siteId = "", string? sourceScript = null, ICachedCallTelemetryForwarder? cachedForwarder = null, - Guid? parentExecutionId = null) + Guid? parentExecutionId = null, + string? sourceNode = null) { _client = client; _instanceName = instanceName; @@ -485,6 +504,7 @@ public class ScriptRuntimeContext _sourceScript = sourceScript; _cachedForwarder = cachedForwarder; _parentExecutionId = parentExecutionId; + _sourceNode = sourceNode; } public async Task Call( @@ -670,9 +690,11 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, - // SourceNode: stamped by Task 14 once the script context - // gets an INodeIdentityProvider; null until then. - SourceNode: null, + // SourceNode-stamping (Task 14): the local node name + // (node-a/node-b) — threaded through INodeIdentityProvider + // at the ScriptExecutionActor; null when no provider was + // wired so central persists SiteCalls.SourceNode as NULL. + SourceNode: _sourceNode, Status: "Submitted", RetryCount: 0, LastError: null, @@ -791,9 +813,11 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, - // SourceNode: stamped by Task 14 once the script context - // gets an INodeIdentityProvider; null until then. - SourceNode: null, + // SourceNode-stamping (Task 14): the local node name + // (node-a/node-b) — threaded through INodeIdentityProvider + // at the ScriptExecutionActor; null when no provider was + // wired so central persists SiteCalls.SourceNode as NULL. + SourceNode: _sourceNode, Status: "Attempted", // RetryCount stays 0 — the operation never reached the // S&F retry sweep, so no retries were performed. @@ -861,9 +885,11 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, - // SourceNode: stamped by Task 14 once the script context - // gets an INodeIdentityProvider; null until then. - SourceNode: null, + // SourceNode-stamping (Task 14): the local node name + // (node-a/node-b) — threaded through INodeIdentityProvider + // at the ScriptExecutionActor; null when no provider was + // wired so central persists SiteCalls.SourceNode as NULL. + SourceNode: _sourceNode, Status: operationalTerminalStatus, RetryCount: 0, LastError: result.Success ? null : result.ErrorMessage, @@ -1120,6 +1146,15 @@ public class ScriptRuntimeContext /// private readonly IAuditWriter? _auditWriter; + /// + /// SourceNode-stamping (Task 14): the local cluster node name on + /// which this script is executing (node-a/node-b). + /// Stamped onto SiteCallOperational.SourceNode at the + /// Database.CachedWrite CachedSubmit telemetry construction + /// site so central can persist it on the SiteCalls row. + /// + private readonly string? _sourceNode; + // Parameter ordering: executionId sits immediately after the // ILogger — see the note on ExternalSystemHelper's ctor for why the // post-logger slot is the one consistent position across all four @@ -1133,7 +1168,8 @@ public class ScriptRuntimeContext string siteId = "", string? sourceScript = null, ICachedCallTelemetryForwarder? cachedForwarder = null, - Guid? parentExecutionId = null) + Guid? parentExecutionId = null, + string? sourceNode = null) { _gateway = gateway; _instanceName = instanceName; @@ -1144,6 +1180,7 @@ public class ScriptRuntimeContext _sourceScript = sourceScript; _cachedForwarder = cachedForwarder; _parentExecutionId = parentExecutionId; + _sourceNode = sourceNode; } public async Task Connection( @@ -1274,9 +1311,11 @@ public class ScriptRuntimeContext Channel: "DbOutbound", Target: target, SourceSite: _siteId, - // SourceNode: stamped by Task 14 once the script context - // gets an INodeIdentityProvider; null until then. - SourceNode: null, + // SourceNode-stamping (Task 14): the local node name + // (node-a/node-b) — threaded through INodeIdentityProvider + // at the ScriptExecutionActor; null when no provider was + // wired so central persists SiteCalls.SourceNode as NULL. + SourceNode: _sourceNode, Status: "Submitted", RetryCount: 0, LastError: null, diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs index 4185ab1..bfde0eb 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs @@ -327,4 +327,68 @@ public class CachedCallLifecycleBridgeTests Assert.NotNull(captured); Assert.Null(captured!.Audit.ParentExecutionId); } + + // ── SourceNode-stamping (Task 14) ── + + [Fact] + public async Task RetryLoopRow_StampsSourceNode_FromNodeIdentityProvider() + { + // SourceNode-stamping (Task 14): when an INodeIdentityProvider is + // wired the bridge stamps the local node name (node-a/node-b) onto + // the SiteCallOperational.SourceNode column of every emitted packet. + var nodeIdentity = Substitute.For(); + nodeIdentity.NodeName.Returns("node-a"); + + var captured = new List(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .Returns(Task.CompletedTask); + + var sut = new CachedCallLifecycleBridge( + _forwarder, NullLogger.Instance, nodeIdentity); + + await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.Delivered)); + + Assert.Equal(2, captured.Count); + Assert.All(captured, p => Assert.Equal("node-a", p.Operational.SourceNode)); + } + + [Fact] + public async Task RetryLoopRow_NoNodeIdentityProvider_LeavesSourceNodeNull() + { + // When no INodeIdentityProvider is wired (legacy hosts / tests) the + // bridge degrades to a null SourceNode rather than throwing. The + // emitted packet's SourceNode is null so the central row persists NULL. + var captured = new List(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .Returns(Task.CompletedTask); + + // Default CreateSut() does NOT pass a node-identity provider. + var sut = CreateSut(); + await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure)); + + var packet = Assert.Single(captured); + Assert.Null(packet.Operational.SourceNode); + } + + [Fact] + public async Task RetryLoopRow_NodeIdentityWithNullNodeName_LeavesSourceNodeNull() + { + // The provider exists but reports a null NodeName (unconfigured). The + // bridge must pass that null through to SourceNode rather than + // falling back to a placeholder. + var nodeIdentity = Substitute.For(); + nodeIdentity.NodeName.Returns((string?)null); + + var captured = new List(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .Returns(Task.CompletedTask); + + var sut = new CachedCallLifecycleBridge( + _forwarder, NullLogger.Instance, nodeIdentity); + + await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure)); + + var packet = Assert.Single(captured); + Assert.Null(packet.Operational.SourceNode); + } } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs index 7be51a6..e5de6d5 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs @@ -135,7 +135,11 @@ public class CachedCallTelemetryForwarderTests Arg.Any()); // Tracking row: insert-if-not-exists with kind discriminator. - // sourceNode is null until Task 14 wires the INodeIdentityProvider through. + // Default CreateSut() does NOT supply an INodeIdentityProvider, so the + // forwarder passes null sourceNode to RecordEnqueueAsync (legacy / test + // host behaviour). The Task 14 stamping path is covered by the + // ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider test + // below. await _tracking.Received(1).RecordEnqueueAsync( _id, "ApiOutbound", @@ -249,4 +253,55 @@ public class CachedCallTelemetryForwarderTests await Assert.ThrowsAsync( () => sut.ForwardAsync(null!, CancellationToken.None)); } + + // ── SourceNode-stamping (Task 14) ── + + [Fact] + public async Task ForwardAsync_Submit_StampsSourceNode_FromNodeIdentityProvider() + { + // SourceNode-stamping (Task 14): when an INodeIdentityProvider is + // wired the forwarder must stamp its NodeName onto the + // RecordEnqueueAsync sourceNode parameter so the tracking row + // captures the originating node (node-a/node-b). + var nodeIdentity = Substitute.For(); + nodeIdentity.NodeName.Returns("node-a"); + + var sut = new CachedCallTelemetryForwarder( + _writer, _tracking, NullLogger.Instance, nodeIdentity); + + await sut.ForwardAsync(SubmitPacket(), CancellationToken.None); + + await _tracking.Received(1).RecordEnqueueAsync( + _id, + "ApiOutbound", + "ERP.GetOrder", + "inst-1", + "ScriptActor:doStuff", + "node-a", + Arg.Any()); + } + + [Fact] + public async Task ForwardAsync_Submit_NodeIdentityNullNodeName_PassesNullSourceNode() + { + // The provider exists but reports a null NodeName (unconfigured). + // The forwarder passes that null through to RecordEnqueueAsync rather + // than falling back to a placeholder string. + var nodeIdentity = Substitute.For(); + nodeIdentity.NodeName.Returns((string?)null); + + var sut = new CachedCallTelemetryForwarder( + _writer, _tracking, NullLogger.Instance, nodeIdentity); + + await sut.ForwardAsync(SubmitPacket(), CancellationToken.None); + + await _tracking.Received(1).RecordEnqueueAsync( + _id, + "ApiOutbound", + "ERP.GetOrder", + "inst-1", + "ScriptActor:doStuff", + null, + Arg.Any()); + } } diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs index 67d93f2..4e77af7 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs @@ -520,7 +520,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture DateTime? createdAtUtc = null, DateTime? updatedAtUtc = null, bool terminal = false, - DateTime? terminalAtUtc = null) + DateTime? terminalAtUtc = null, + string? sourceNode = null) { var created = createdAtUtc ?? DateTime.UtcNow; var updated = updatedAtUtc ?? created; @@ -534,6 +535,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture Channel = "ApiOutbound", Target = "ERP.GetOrder", SourceSite = sourceSite ?? NewSiteId(), + SourceNode = sourceNode, Status = status, RetryCount = retryCount, LastError = lastError, @@ -544,4 +546,159 @@ public class SiteCallAuditRepositoryTests : IClassFixture IngestedAtUtc = DateTime.UtcNow, }; } + + // --- SourceNode-stamping (Task 14) -------------------------------------- + + [SkippableFact] + public async Task UpsertAsync_PersistsSourceNode_OnFreshInsert() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // SourceNode-stamping (Task 14): a fresh INSERT must persist the + // SourceNode column verbatim — the central row carries the originating + // site node name end-to-end. + var id = TrackedOperationId.New(); + await using var context = CreateContext(); + var repo = new SiteCallAuditRepository(context); + + await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a")); + + var loaded = await repo.GetAsync(id); + Assert.NotNull(loaded); + Assert.Equal("node-a", loaded!.SourceNode); + } + + [SkippableFact] + public async Task UpsertAsync_PreservesSourceNode_WhenLaterPacketCarriesNull() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // SourceNode-stamping (Task 14): the UPDATE uses + // COALESCE(@SourceNode, SourceNode) so a subsequent packet that does + // NOT carry a SourceNode (legacy / reconciliation pull from an + // unstamped node) MUST NOT blank out the value the first packet set. + // Combined with the monotonic-rank guard the Status advances but the + // SourceNode survives. + // + // Each step uses a fresh DbContext — raw-SQL UPDATEs bypass the + // change tracker, so reusing a single context whose entity is already + // tracked masks the post-UPDATE state on a follow-up FindAsync. + var id = TrackedOperationId.New(); + await using (var context = CreateContext()) + { + var repo = new SiteCallAuditRepository(context); + // First packet: stamped Submit from node-a. + await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a")); + } + await using (var context = CreateContext()) + { + var repo = new SiteCallAuditRepository(context); + // Later packet: rank-advancing Attempted with null SourceNode. + await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: null)); + } + + await using (var readContext = CreateContext()) + { + var readRepo = new SiteCallAuditRepository(readContext); + var loaded = await readRepo.GetAsync(id); + Assert.NotNull(loaded); + // SourceNode preserved despite the null on the later packet. + Assert.Equal("node-a", loaded!.SourceNode); + // Status advanced — proves the UPDATE branch actually ran. + Assert.Equal("Attempted", loaded.Status); + Assert.Equal(1, loaded.RetryCount); + } + } + + [SkippableFact] + public async Task UpsertAsync_NonNullIncomingSourceNode_OverwritesPreviousValueOnRankAdvance() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // SourceNode-stamping (Task 14): per the COALESCE(@SourceNode, + // SourceNode) semantics the column protects against a *null* + // incoming value blanking a previously-stamped one, but a non-null + // incoming value DOES replace the existing value on a rank-advancing + // packet. This is the "last-non-null-wins on advance" behaviour the + // SQL operator literally implements — see the comment in + // SiteCallAuditRepository.UpsertAsync. + // + // In practice both stamps within a single lifecycle SHOULD carry the + // same value (same node, same execution); a divergence would imply a + // mid-lifecycle node change (e.g. failover handing off to node-b) and + // letting the latest stamp through is arguably the right call. This + // test pins the actual behaviour so we notice if the SQL gets + // inverted (to a true first-write-wins COALESCE(SourceNode, + // @SourceNode)) inadvertently. + var id = TrackedOperationId.New(); + await using (var context = CreateContext()) + { + var repo = new SiteCallAuditRepository(context); + await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a")); + } + await using (var context = CreateContext()) + { + var repo = new SiteCallAuditRepository(context); + await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-b")); + } + + await using (var readContext = CreateContext()) + { + var readRepo = new SiteCallAuditRepository(readContext); + var loaded = await readRepo.GetAsync(id); + Assert.NotNull(loaded); + // Incoming non-null wins — node-b replaces node-a on rank advance. + Assert.Equal("node-b", loaded!.SourceNode); + // Other monotonic fields advanced too — proves the UPDATE ran. + Assert.Equal("Attempted", loaded.Status); + } + } + + [SkippableFact] + public async Task UpsertAsync_FillsSourceNode_WhenInsertWasNullAndLaterPacketCarriesValue() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // SourceNode-stamping (Task 14): when the column was left NULL by an + // earlier unstamped packet, a later rank-advancing packet with a + // non-null SourceNode fills it — the COALESCE(@SourceNode, SourceNode) + // SQL operator returns @SourceNode when @SourceNode is non-null, so + // the incoming value wins over the existing NULL. This is the + // recovery path for an initially-unstamped lifecycle whose later + // packets carry the node identity. + // + // The intermediate verification and final read use FRESH contexts — + // FindAsync hits the change tracker first, so a cached entity from + // an earlier read in the same context can mask a raw-SQL UPDATE. + var id = TrackedOperationId.New(); + await using (var context = CreateContext()) + { + var repo = new SiteCallAuditRepository(context); + await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: null)); + } + + // Verify the INSERT left SourceNode NULL via a fresh context. + await using (var verifyContext = CreateContext()) + { + var verifyRepo = new SiteCallAuditRepository(verifyContext); + var afterInsert = await verifyRepo.GetAsync(id); + Assert.NotNull(afterInsert); + Assert.Null(afterInsert!.SourceNode); + } + + await using (var context = CreateContext()) + { + var repo = new SiteCallAuditRepository(context); + await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-a")); + } + + await using (var readContext = CreateContext()) + { + var readRepo = new SiteCallAuditRepository(readContext); + var loaded = await readRepo.GetAsync(id); + Assert.NotNull(loaded); + Assert.Equal("node-a", loaded!.SourceNode); + Assert.Equal("Attempted", loaded.Status); + } + } } diff --git a/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs b/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs index 3c39257..42093c5 100644 --- a/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs +++ b/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs @@ -49,7 +49,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture(TimeSpan.FromSeconds(10)); + Assert.True(reply.Accepted); + + await using var readContext = CreateContext(); + var stored = await readContext.Set() + .Where(s => s.TrackedOperationId == id) + .ToListAsync(); + Assert.Single(stored); + Assert.Equal("node-a", stored[0].SourceNode); + } + + [SkippableFact] + public async Task UpsertSiteCallCommand_NullSourceNode_PersistsNull() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + // Mirror of the above for unstamped packets — a command with null + // SourceNode persists NULL on the row rather than falling back to a + // placeholder. The first audit packet from a legacy host (or a node + // without INodeIdentityProvider wired) must NOT inject a fabricated + // value central-side. + var siteId = NewSiteId(); + var id = TrackedOperationId.New(); + var row = NewRow(id, siteId, status: "Submitted", sourceNode: null); + + await using var context = CreateContext(); + var repo = new SiteCallAuditRepository(context); + var actor = CreateActor(repo); + + actor.Tell(new UpsertSiteCallCommand(row), TestActor); + var reply = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.True(reply.Accepted); + + await using var readContext = CreateContext(); + var stored = await readContext.Set() + .Where(s => s.TrackedOperationId == id) + .ToListAsync(); + Assert.Single(stored); + Assert.Null(stored[0].SourceNode); + } + /// /// Test double whose always /// throws — used to verify the query handler's failure projection produces a diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index 7ed9725..ac90f4f 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -570,4 +570,69 @@ public class ExternalSystemCachedCallEmissionTests var only = Assert.Single(forwarder.Telemetry); Assert.Equal(AuditKind.CachedSubmit, only.Audit.Kind); } + + // ── SourceNode-stamping (Task 14) ── + + [Fact] + public async Task CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow() + { + // SourceNode-stamping (Task 14): when the helper is constructed with + // a non-null sourceNode, every SiteCallOperational it produces + // (CachedSubmit on enqueue + the immediate-completion Attempted/ + // CachedResolve pair when WasBuffered=false) carries that node name. + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + // Immediate completion — helper produces all three rows itself. + .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); + var forwarder = new CapturingForwarder(); + + var helper = new ScriptRuntimeContext.ExternalSystemHelper( + client.Object, + InstanceName, + NullLogger.Instance, + TestExecutionId, + auditWriter: null, + siteId: SiteId, + sourceScript: SourceScript, + cachedForwarder: forwarder, + parentExecutionId: null, + sourceNode: "node-a"); + + await helper.CachedCall("ERP", "GetOrder"); + + Assert.Equal(3, forwarder.Telemetry.Count); + Assert.All(forwarder.Telemetry, t => Assert.Equal("node-a", t.Operational.SourceNode)); + } + + [Fact] + public async Task CachedCall_NoSourceNodeWired_LeavesSourceNodeNull() + { + // Default CreateHelper does NOT pass sourceNode — the legacy / test + // host path. Every operational row carries null SourceNode, leaving + // central's SiteCalls.SourceNode NULL. + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(client.Object, forwarder); + await helper.CachedCall("ERP", "GetOrder"); + + var only = Assert.Single(forwarder.Telemetry); + Assert.Null(only.Operational.SourceNode); + } } From 466e1454fe493fcdc10d723978fda0f21ca9f91c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 17:50:14 -0400 Subject: [PATCH 18/23] test(sitecall-audit): symmetric SourceNode coverage on DbOutbound emitter + clarify DI comments Two follow-ups from the T13/T14 code review: - M1: Add CachedWrite_StampsSourceNode_OnSubmitTelemetryRow and CachedWrite_NoSourceNodeWired_LeavesSourceNodeNull to DatabaseCachedWriteEmissionTests, mirroring the existing ApiOutbound SourceNode tests in ExternalSystemCachedCallEmissionTests. Site-emitter coverage now symmetric across both cached-call channels. - M2: Clarify the GetService(INodeIdentityProvider) DI comments on the CachedCallTelemetryForwarder and CachedCallLifecycleBridge factories: it's test composition roots that may not register the provider, not central production. Both site and central hosts always register it via SiteServiceRegistration.BindSharedOptions. --- .../ServiceCollectionExtensions.cs | 19 ++++-- .../DatabaseCachedWriteEmissionTests.cs | 64 +++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index a62e02f..d97917f 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -149,10 +149,13 @@ public static class ServiceCollectionExtensions sp.GetRequiredService>(), // SourceNode-stamping (Task 14): the local node identity is // threaded through so RecordEnqueueAsync can stamp the - // tracking row's SourceNode column. GetService — central - // composition roots may not register the provider, in which - // case the forwarder degrades to a null SourceNode rather - // than failing the DI resolution. + // tracking row's SourceNode column. GetService (not + // GetRequiredService) — test composition roots that build a + // stripped DI container may not register the provider, in + // which case the forwarder degrades to a null SourceNode + // rather than failing the DI resolution. Production hosts + // (site + central) always register it via + // SiteServiceRegistration.BindSharedOptions. sp.GetService())); // M3 Bundle F: bridge the store-and-forward retry-loop observer hook @@ -165,9 +168,11 @@ public static class ServiceCollectionExtensions // INodeIdentityProvider singleton can be threaded through — the // bridge stamps SiteCallOperational.SourceNode from // INodeIdentityProvider.NodeName on every cached-call lifecycle row. - // GetService (not GetRequiredService) — central composition roots may - // not register the provider, in which case the bridge degrades to a - // null SourceNode rather than failing the DI resolution. + // GetService (not GetRequiredService) — test composition roots that + // build a stripped DI container may not register the provider, in + // which case the bridge degrades to a null SourceNode rather than + // failing the DI resolution. Production hosts (site + central) + // always register it via SiteServiceRegistration.BindSharedOptions. services.AddSingleton(sp => new CachedCallLifecycleBridge( sp.GetRequiredService(), sp.GetRequiredService>(), diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index b993083..2f87363 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -328,4 +328,68 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + + // ── SourceNode-stamping (Task 14) ── + + [Fact] + public async Task CachedWrite_StampsSourceNode_OnSubmitTelemetryRow() + { + // Symmetric to ExternalSystemCachedCallEmissionTests's + // CachedCall_StampsSourceNode_OnEverySiteCallOperationalRow — locks + // the DbOutbound emitter against a future refactor that drops + // _sourceNode from the Database.CachedWrite CachedSubmit row. + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var forwarder = new CapturingForwarder(); + + var helper = new ScriptRuntimeContext.DatabaseHelper( + gateway.Object, + InstanceName, + NullLogger.Instance, + TestExecutionId, + auditWriter: null, + siteId: SiteId, + sourceScript: SourceScript, + cachedForwarder: forwarder, + parentExecutionId: null, + sourceNode: "node-a"); + + await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)"); + + var packet = Assert.Single(forwarder.Telemetry); + Assert.Equal("node-a", packet.Operational.SourceNode); + } + + [Fact] + public async Task CachedWrite_NoSourceNodeWired_LeavesSourceNodeNull() + { + // Default CreateHelper does NOT pass sourceNode — the legacy / test + // host path. The operational row carries null SourceNode, leaving + // central's SiteCalls.SourceNode NULL. + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(gateway.Object, forwarder); + await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)"); + + var packet = Assert.Single(forwarder.Telemetry); + Assert.Null(packet.Operational.SourceNode); + } } From bb29d65a945a4590e5aa27630166779334cde7d0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 18:01:36 -0400 Subject: [PATCH 19/23] feat(ui): add Node column + filter to AuditLog grid --- .../Components/Audit/AuditFilterBar.razor | 17 ++++ .../Components/Audit/AuditFilterBar.razor.cs | 24 ++++++ .../Components/Audit/AuditQueryModel.cs | 11 ++- .../Components/Audit/AuditResultsGrid.razor | 3 + .../Audit/AuditResultsGrid.razor.cs | 1 + .../Services/AuditLogQueryService.cs | 50 +++++++++++ .../Services/IAuditLogQueryService.cs | 11 +++ .../Repositories/IAuditLogRepository.cs | 8 ++ .../Types/Audit/AuditLogQueryFilter.cs | 23 +++-- .../Repositories/AuditLogRepository.cs | 26 ++++++ .../Central/AuditLogIngestActorTests.cs | 3 + .../Central/AuditLogPurgeActorTests.cs | 3 + .../Central/CentralAuditWriteFailuresTests.cs | 3 + .../SiteAuditReconciliationActorTests.cs | 3 + .../Components/Audit/AuditFilterBarTests.cs | 40 +++++++++ .../Components/Audit/AuditResultsGridTests.cs | 22 +++++ .../Services/AuditLogQueryServiceTests.cs | 64 ++++++++++++++ .../Repositories/AuditLogRepositoryTests.cs | 85 +++++++++++++++++++ .../AuditLog/SiteAuditPushFlowTests.cs | 3 + 19 files changed, 392 insertions(+), 8 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor index 0c06025..9e0905a 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor @@ -1,8 +1,10 @@ +@using ScadaLink.CentralUI.Services @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Types.Audit @using ScadaLink.Commons.Types.Enums @inject ISiteRepository SiteRepository +@inject IAuditLogQueryService AuditLogQueryService
@@ -58,6 +60,21 @@
+ @* Node multi-select. Options are the distinct SourceNode values + observed in the AuditLog table; the service-side lookup is cached + for 60s so a render of this bar costs at most one DB hit per + minute per circuit. *@ +
+ +
+ +
+
+
+ @* Task 16: free-text Node filter — exact match against the + notification's SourceNode column. Sites + central nodes + both flow through this single input. *@ +
+ + +
Status Retries Source site + Node Created Delivered Actions @@ -162,6 +173,7 @@ @n.RetryCount @SiteName(n.SourceSiteId) + @(n.SourceNode ?? "—") @@ -253,6 +265,9 @@
Source site
@SiteName(d.SourceSiteId)
+
Source node
+
@(string.IsNullOrEmpty(d.SourceNode) ? "—" : d.SourceNode)
+
Source instance
@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)
@@ -372,6 +387,7 @@ private string _siteFilter = string.Empty; private string _listFilter = string.Empty; private string _subjectFilter = string.Empty; + private string _nodeFilter = string.Empty; private bool _stuckOnly; private DateTime? _fromFilter; private DateTime? _toFilter; @@ -422,7 +438,8 @@ From: ToUtc(_fromFilter), To: ToUtc(_toFilter), PageNumber: _pageNumber, - PageSize: _pageSize); + PageSize: _pageSize, + SourceNodeFilter: NullIfEmpty(_nodeFilter)); var response = await CommunicationService.QueryNotificationOutboxAsync(request); if (response.Success) @@ -597,6 +614,7 @@ _siteFilter = string.Empty; _listFilter = string.Empty; _subjectFilter = string.Empty; + _nodeFilter = string.Empty; _stuckOnly = false; _fromFilter = null; _toFilter = null; @@ -608,6 +626,7 @@ !string.IsNullOrEmpty(_siteFilter) || !string.IsNullOrEmpty(_listFilter) || !string.IsNullOrEmpty(_subjectFilter) || + !string.IsNullOrEmpty(_nodeFilter) || _stuckOnly || _fromFilter != null || _toFilter != null; diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs index 12151d2..27bd888 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs @@ -17,7 +17,8 @@ public record NotificationOutboxQueryRequest( DateTimeOffset? From, DateTimeOffset? To, int PageNumber, - int PageSize); + int PageSize, + string? SourceNodeFilter = null); /// /// A single notification row summarised for outbox UI display. @@ -34,7 +35,8 @@ public record NotificationSummary( string? SourceInstanceId, DateTimeOffset CreatedAt, DateTimeOffset? DeliveredAt, - bool IsStuck); + bool IsStuck, + string? SourceNode = null); /// /// Central -> Outbox UI: paginated response for a . diff --git a/src/ScadaLink.Commons/Types/Notifications/NotificationOutboxFilter.cs b/src/ScadaLink.Commons/Types/Notifications/NotificationOutboxFilter.cs index 0b405b1..dc3e1eb 100644 --- a/src/ScadaLink.Commons/Types/Notifications/NotificationOutboxFilter.cs +++ b/src/ScadaLink.Commons/Types/Notifications/NotificationOutboxFilter.cs @@ -18,6 +18,11 @@ namespace ScadaLink.Commons.Types.Notifications; /// Rows with CreatedAt older than this count as stuck. /// Inclusive lower bound on CreatedAt. /// Inclusive upper bound on CreatedAt. +/// +/// Restrict to notifications originating at a specific cluster node (e.g. +/// "central-a", "site-plant-a-node-a"). Exact match; null +/// means "do not constrain". +/// public record NotificationOutboxFilter( NotificationStatus? Status = null, NotificationType? Type = null, @@ -27,4 +32,5 @@ public record NotificationOutboxFilter( bool StuckOnly = false, DateTimeOffset? StuckCutoff = null, DateTimeOffset? From = null, - DateTimeOffset? To = null); + DateTimeOffset? To = null, + string? SourceNode = null); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs index 78bab14..0a8a0c3 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs @@ -83,6 +83,13 @@ public class NotificationOutboxRepository : INotificationOutboxRepository query = query.Where(n => n.SourceSiteId == filter.SourceSiteId); } + // Task 16: SourceNode is exact-match like SourceSiteId. Rows with NULL + // SourceNode (legacy / unconfigured) are excluded when the filter is set. + if (!string.IsNullOrEmpty(filter.SourceNode)) + { + query = query.Where(n => n.SourceNode == filter.SourceNode); + } + if (!string.IsNullOrEmpty(filter.ListName)) { query = query.Where(n => n.ListName == filter.ListName); diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index 4d2cc85..c7a91ef 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -626,7 +626,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers StuckOnly: request.StuckOnly, StuckCutoff: request.StuckOnly ? StuckCutoff(now) : null, From: request.From, - To: request.To); + To: request.To, + SourceNode: request.SourceNodeFilter); using var scope = _serviceProvider.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); @@ -646,7 +647,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers row.SourceInstanceId, row.CreatedAt, row.DeliveredAt, - IsStuck: IsStuck(row, stuckCutoff))) + IsStuck: IsStuck(row, stuckCutoff), + SourceNode: row.SourceNode)) .ToList(); return new NotificationOutboxQueryResponse( diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs index 9c2860b..a7fa0e4 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs @@ -149,6 +149,45 @@ public class NotificationOutboxActorQueryTests : TestKit 1, 50, Arg.Any()); } + [Fact] + public void Query_PassesSourceNodeFilter_AndProjectsSourceNodeOntoSummary() + { + // Task 16: the Notifications page's new Node filter input pushes a + // value into NotificationOutboxQueryRequest.SourceNodeFilter; the actor + // must thread it onto NotificationOutboxFilter.SourceNode AND mirror + // the row's SourceNode column onto the response summaries. + var row = MakeNotification(status: NotificationStatus.Pending); + row.SourceNode = "central-a"; + _repository.QueryAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(((IReadOnlyList)new[] { row }, 1)); + var actor = CreateActor(); + + actor.Tell( + new NotificationOutboxQueryRequest( + CorrelationId: "corr-node", + StatusFilter: null, + TypeFilter: null, + SourceSiteFilter: null, + ListNameFilter: null, + StuckOnly: false, + SubjectKeyword: null, + From: null, + To: null, + PageNumber: 1, + PageSize: 50, + SourceNodeFilter: "central-a"), + TestActor); + + var response = ExpectMsg(); + Assert.True(response.Success); + Assert.Equal("central-a", response.Notifications.Single().SourceNode); + + _repository.Received(1).QueryAsync( + Arg.Is(f => f.SourceNode == "central-a"), + 1, 50, Arg.Any()); + } + [Fact] public void Query_RepositoryThrows_RepliesFailureWithEmptyList() { From d18a6e6fa00bde9fdf89fe34de3f2fd7b8b1dcde Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 18:08:25 -0400 Subject: [PATCH 21/23] feat(ui): add Node column + filter to SiteCalls grid --- .../Pages/SiteCalls/SiteCallsReport.razor | 16 ++++++++ .../Pages/SiteCalls/SiteCallsReport.razor.cs | 6 ++- .../Messages/Audit/SiteCallQueries.cs | 6 ++- .../Types/Audit/SiteCallQueryFilter.cs | 8 +++- .../Repositories/SiteCallAuditRepository.cs | 1 + .../SiteCallAuditActor.cs | 6 ++- .../SiteCallAuditActorTests.cs | 37 +++++++++++++++++++ 7 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor index 30ece7b..511c24c 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor @@ -58,6 +58,17 @@ }
+ @* Task 17: free-text Node filter — exact match against the + SiteCall.SourceNode column. The Source site dropdown narrows + to a site; Node narrows further within that site (or across + sites if Source site is "Any"). *@ +
+ + +
Tracked operation Source site + Node Channel Target Status @@ -143,6 +155,7 @@ title="Double-click for full detail"> @ShortId(c.TrackedOperationId) @SiteName(c.SourceSite) + @(c.SourceNode ?? "—") @c.Channel @c.Target @@ -253,6 +266,9 @@
Source site
@SiteName(det.SourceSite)
+
Source node
+
@(string.IsNullOrEmpty(d.SourceNode) ? "—" : d.SourceNode)
+
Channel
@det.Channel
diff --git a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs index c8f0a21..726a025 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs @@ -82,6 +82,7 @@ public partial class SiteCallsReport private string _channelFilter = string.Empty; private string _siteFilter = string.Empty; private string _targetFilter = string.Empty; + private string _nodeFilter = string.Empty; private bool _stuckOnly; private DateTime? _fromFilter; private DateTime? _toFilter; @@ -204,7 +205,8 @@ public partial class SiteCallsReport ToUtc: ToUtc(_toFilter), AfterCreatedAtUtc: cursor.AfterCreatedAtUtc, AfterId: cursor.AfterId, - PageSize: PageSize); + PageSize: PageSize, + SourceNodeFilter: NullIfEmpty(_nodeFilter)); var response = await CommunicationService.QuerySiteCallsAsync(request); if (response.Success) @@ -393,6 +395,7 @@ public partial class SiteCallsReport _channelFilter = string.Empty; _siteFilter = string.Empty; _targetFilter = string.Empty; + _nodeFilter = string.Empty; _stuckOnly = false; _fromFilter = null; _toFilter = null; @@ -403,6 +406,7 @@ public partial class SiteCallsReport !string.IsNullOrEmpty(_channelFilter) || !string.IsNullOrEmpty(_siteFilter) || !string.IsNullOrEmpty(_targetFilter) || + !string.IsNullOrEmpty(_nodeFilter) || _stuckOnly || _fromFilter != null || _toFilter != null; diff --git a/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs b/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs index d5c98a4..93658c5 100644 --- a/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs +++ b/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs @@ -33,7 +33,8 @@ public sealed record SiteCallQueryRequest( DateTime? ToUtc, DateTime? AfterCreatedAtUtc, Guid? AfterId, - int PageSize); + int PageSize, + string? SourceNodeFilter = null); /// /// A single SiteCalls row summarised for the Site Calls UI grid. Carries @@ -61,7 +62,8 @@ public sealed record SiteCallSummary( DateTime CreatedAtUtc, DateTime UpdatedAtUtc, DateTime? TerminalAtUtc, - bool IsStuck); + bool IsStuck, + string? SourceNode = null); /// /// Central -> Site Calls UI: paginated response for a . diff --git a/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs index 63f0c58..3624191 100644 --- a/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs +++ b/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs @@ -26,6 +26,11 @@ namespace ScadaLink.Commons.Types.Audit; /// keeps the "StuckOnly" filter honest so paging never returns under-filled /// pages with a non-null next cursor. /// +/// +/// Restrict to cached calls originating at a specific cluster node (e.g. +/// "site-plant-a-node-a"). Exact match; null means "do not +/// constrain". Rows with NULL SourceNode are excluded when set. +/// public sealed record SiteCallQueryFilter( string? Channel = null, string? SourceSite = null, @@ -33,4 +38,5 @@ public sealed record SiteCallQueryFilter( string? Target = null, DateTime? FromUtc = null, DateTime? ToUtc = null, - DateTime? StuckCutoffUtc = null); + DateTime? StuckCutoffUtc = null, + string? SourceNode = null); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index a06282f..ca3c226 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -204,6 +204,7 @@ SELECT TOP ({paging.PageSize}) FROM dbo.SiteCalls WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel}) AND ({filter.SourceSite} IS NULL OR SourceSite = {filter.SourceSite}) + AND ({filter.SourceNode} IS NULL OR SourceNode = {filter.SourceNode}) AND ({filter.Status} IS NULL OR Status = {filter.Status}) AND ({filter.Target} IS NULL OR Target = {filter.Target}) AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc}) diff --git a/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs b/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs index 8078060..7539f3a 100644 --- a/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs +++ b/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs @@ -232,7 +232,8 @@ public class SiteCallAuditActor : ReceiveActor // TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff composes with the // keyset cursor, so the page is always honest (full pages, no empty // pages with a non-null next cursor). - StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null); + StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null, + SourceNode: NullIfBlank(request.SourceNodeFilter)); var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize); var paging = new SiteCallPaging( @@ -633,7 +634,8 @@ public class SiteCallAuditActor : ReceiveActor CreatedAtUtc: row.CreatedAtUtc, UpdatedAtUtc: row.UpdatedAtUtc, TerminalAtUtc: row.TerminalAtUtc, - IsStuck: IsStuck(row, stuckCutoff)); + IsStuck: IsStuck(row, stuckCutoff), + SourceNode: row.SourceNode); } private static SiteCallDetail ToDetail(SiteCall row) diff --git a/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs b/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs index 42093c5..0d6fd26 100644 --- a/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs +++ b/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs @@ -223,6 +223,43 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture(TimeSpan.FromSeconds(10)); + Assert.True(response.Success); + Assert.Single(response.SiteCalls); + Assert.Equal("site-plant-a-node-a", response.SiteCalls[0].SourceNode); + Assert.Equal("Attempted", response.SiteCalls[0].Status); + } + [SkippableFact] public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor() { From 8bf84fb7f3e4d632a6b0d61c202f758cb7a59103 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 18:16:42 -0400 Subject: [PATCH 22/23] chore(docker): set NodeName on all 8 cluster nodes Adds "NodeName" to the ScadaLink:Node section of each per-node appsettings: - central-a, central-b for the two central nodes - node-a, node-b under each of the three sites (site-a, site-b, site-c) After this commit + a redeploy, every fresh AuditLog / Notifications / SiteCalls row gets stamped with the originating node's role name via INodeIdentityProvider, satisfying the design's SourceNode invariant end-to-end. --- docker/central-node-a/appsettings.Central.json | 1 + docker/central-node-b/appsettings.Central.json | 1 + docker/site-a-node-a/appsettings.Site.json | 1 + docker/site-a-node-b/appsettings.Site.json | 1 + docker/site-b-node-a/appsettings.Site.json | 1 + docker/site-b-node-b/appsettings.Site.json | 1 + docker/site-c-node-a/appsettings.Site.json | 1 + docker/site-c-node-b/appsettings.Site.json | 1 + 8 files changed, 8 insertions(+) diff --git a/docker/central-node-a/appsettings.Central.json b/docker/central-node-a/appsettings.Central.json index 79dec11..8b3a492 100644 --- a/docker/central-node-a/appsettings.Central.json +++ b/docker/central-node-a/appsettings.Central.json @@ -2,6 +2,7 @@ "ScadaLink": { "Node": { "Role": "Central", + "NodeName": "central-a", "NodeHostname": "scadalink-central-a", "RemotingPort": 8081 }, diff --git a/docker/central-node-b/appsettings.Central.json b/docker/central-node-b/appsettings.Central.json index 66dc488..1192be5 100644 --- a/docker/central-node-b/appsettings.Central.json +++ b/docker/central-node-b/appsettings.Central.json @@ -2,6 +2,7 @@ "ScadaLink": { "Node": { "Role": "Central", + "NodeName": "central-b", "NodeHostname": "scadalink-central-b", "RemotingPort": 8081 }, diff --git a/docker/site-a-node-a/appsettings.Site.json b/docker/site-a-node-a/appsettings.Site.json index e91211c..807e372 100644 --- a/docker/site-a-node-a/appsettings.Site.json +++ b/docker/site-a-node-a/appsettings.Site.json @@ -2,6 +2,7 @@ "ScadaLink": { "Node": { "Role": "Site", + "NodeName": "node-a", "NodeHostname": "scadalink-site-a-a", "SiteId": "site-a", "RemotingPort": 8082, diff --git a/docker/site-a-node-b/appsettings.Site.json b/docker/site-a-node-b/appsettings.Site.json index d24010a..3af07f8 100644 --- a/docker/site-a-node-b/appsettings.Site.json +++ b/docker/site-a-node-b/appsettings.Site.json @@ -2,6 +2,7 @@ "ScadaLink": { "Node": { "Role": "Site", + "NodeName": "node-b", "NodeHostname": "scadalink-site-a-b", "SiteId": "site-a", "RemotingPort": 8082, diff --git a/docker/site-b-node-a/appsettings.Site.json b/docker/site-b-node-a/appsettings.Site.json index 0757d32..3e826b6 100644 --- a/docker/site-b-node-a/appsettings.Site.json +++ b/docker/site-b-node-a/appsettings.Site.json @@ -2,6 +2,7 @@ "ScadaLink": { "Node": { "Role": "Site", + "NodeName": "node-a", "NodeHostname": "scadalink-site-b-a", "SiteId": "site-b", "RemotingPort": 8082, diff --git a/docker/site-b-node-b/appsettings.Site.json b/docker/site-b-node-b/appsettings.Site.json index e31ed83..72a5520 100644 --- a/docker/site-b-node-b/appsettings.Site.json +++ b/docker/site-b-node-b/appsettings.Site.json @@ -2,6 +2,7 @@ "ScadaLink": { "Node": { "Role": "Site", + "NodeName": "node-b", "NodeHostname": "scadalink-site-b-b", "SiteId": "site-b", "RemotingPort": 8082, diff --git a/docker/site-c-node-a/appsettings.Site.json b/docker/site-c-node-a/appsettings.Site.json index 3694920..faad6d5 100644 --- a/docker/site-c-node-a/appsettings.Site.json +++ b/docker/site-c-node-a/appsettings.Site.json @@ -2,6 +2,7 @@ "ScadaLink": { "Node": { "Role": "Site", + "NodeName": "node-a", "NodeHostname": "scadalink-site-c-a", "SiteId": "site-c", "RemotingPort": 8082, diff --git a/docker/site-c-node-b/appsettings.Site.json b/docker/site-c-node-b/appsettings.Site.json index 13c5b73..8b23299 100644 --- a/docker/site-c-node-b/appsettings.Site.json +++ b/docker/site-c-node-b/appsettings.Site.json @@ -2,6 +2,7 @@ "ScadaLink": { "Node": { "Role": "Site", + "NodeName": "node-b", "NodeHostname": "scadalink-site-c-b", "SiteId": "site-c", "RemotingPort": 8082, From c754666a3d38db6ae4ed211519ac6f256464ab40 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 18:37:53 -0400 Subject: [PATCH 23/23] fix(ui): carry SourceNode on SiteCallDetail + NotificationDetail records The Site Calls and Notifications detail modals were reading SourceNode from the summary record (d.SourceNode) while every other field read from the detail record (det.X). The pattern works today because the modal always opens via a row click that pre-loads the summary, but a future drill-in from a deep link or refresh path could leave the summary stale or null and the field would render blank or wrong. Add SourceNode to both detail records, project it through the actor's ToDetail mapping, and switch the razor markup to read det.SourceNode. Now the modal binds uniformly to the detail record across all fields. --- .../Components/Pages/Notifications/NotificationReport.razor | 2 +- .../Components/Pages/SiteCalls/SiteCallsReport.razor | 2 +- src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs | 3 ++- .../Messages/Notification/NotificationOutboxQueries.cs | 3 ++- src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs | 3 ++- src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs | 3 ++- .../NotificationOutboxActorQueryTests.cs | 5 +++++ .../SiteCallAuditActorTests.cs | 6 +++++- 8 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor index 207f402..a1e2c1e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor @@ -266,7 +266,7 @@
@SiteName(d.SourceSiteId)
Source node
-
@(string.IsNullOrEmpty(d.SourceNode) ? "—" : d.SourceNode)
+
@(string.IsNullOrEmpty(_detail?.SourceNode) ? "—" : _detail.SourceNode)
Source instance
@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)
diff --git a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor index 511c24c..d24af8c 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor @@ -267,7 +267,7 @@
@SiteName(det.SourceSite)
Source node
-
@(string.IsNullOrEmpty(d.SourceNode) ? "—" : d.SourceNode)
+
@(string.IsNullOrEmpty(det.SourceNode) ? "—" : det.SourceNode)
Channel
@det.Channel
diff --git a/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs b/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs index 93658c5..4245e3c 100644 --- a/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs +++ b/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs @@ -119,7 +119,8 @@ public sealed record SiteCallDetail( DateTime CreatedAtUtc, DateTime UpdatedAtUtc, DateTime? TerminalAtUtc, - DateTime IngestedAtUtc); + DateTime IngestedAtUtc, + string? SourceNode = null); /// /// Site Calls UI -> Central: request for the global SiteCalls KPI summary. diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs index 27bd888..e6b6365 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs @@ -119,7 +119,8 @@ public record NotificationDetail( DateTimeOffset CreatedAt, DateTimeOffset? LastAttemptAt, DateTimeOffset? NextAttemptAt, - DateTimeOffset? DeliveredAt); + DateTimeOffset? DeliveredAt, + string? SourceNode = null); /// /// Outbox UI -> Central: request for the notification outbox KPI summary. diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index c7a91ef..8fc6956 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -749,7 +749,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers notification.CreatedAt, notification.LastAttemptAt, notification.NextAttemptAt, - notification.DeliveredAt); + notification.DeliveredAt, + notification.SourceNode); return new NotificationDetailResponse( request.CorrelationId, Success: true, ErrorMessage: null, detail); diff --git a/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs b/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs index 7539f3a..cfc29ce 100644 --- a/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs +++ b/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs @@ -652,7 +652,8 @@ public class SiteCallAuditActor : ReceiveActor CreatedAtUtc: row.CreatedAtUtc, UpdatedAtUtc: row.UpdatedAtUtc, TerminalAtUtc: row.TerminalAtUtc, - IngestedAtUtc: row.IngestedAtUtc); + IngestedAtUtc: row.IngestedAtUtc, + SourceNode: row.SourceNode); } /// diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs index a7fa0e4..4f71714 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs @@ -354,6 +354,7 @@ public class NotificationOutboxActorQueryTests : TestKit row.ResolvedTargets = "[\"ops@example.com\",\"oncall@example.com\"]"; row.TypeData = "{\"priority\":\"high\"}"; row.SourceScript = "HighLevelAlarm.csx"; + row.SourceNode = "node-a"; row.SiteEnqueuedAt = DateTimeOffset.UtcNow.AddMinutes(-5); row.DeliveredAt = DateTimeOffset.UtcNow; _repository.GetByIdAsync(row.NotificationId, Arg.Any()).Returns(row); @@ -377,6 +378,10 @@ public class NotificationOutboxActorQueryTests : TestKit Assert.Equal("instance-42", detail.SourceInstanceId); Assert.Equal(2, detail.RetryCount); Assert.Equal("transient blip", detail.LastError); + // SourceNode flows through the detail projection so the report detail + // modal binds uniformly to the detail record (was previously read off + // the summary). + Assert.Equal("node-a", detail.SourceNode); } [Fact] diff --git a/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs b/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs index 0d6fd26..d181413 100644 --- a/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs +++ b/tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs @@ -402,7 +402,8 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture