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/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, 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..bd21313 --- /dev/null +++ b/docs/plans/2026-05-23-audit-source-node.md @@ -0,0 +1,940 @@ +# 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); + +// 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 (`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** + +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 diff --git a/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs b/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs index 80bfc45..9f07968 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 }; @@ -114,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/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index e0d9e65..d97917f 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -146,7 +146,17 @@ 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 (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 // to the cached-call forwarder so per-attempt + terminal telemetry @@ -154,7 +164,19 @@ 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) — 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>(), + sp.GetService())); services.AddSingleton( sp => sp.GetRequiredService()); @@ -183,7 +205,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.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index 49a6d86..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"; @@ -100,6 +104,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 +149,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 +283,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 +302,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 +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; + // 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; @@ -386,7 +409,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 +458,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 +545,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 +711,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/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs index 121370f..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,6 +173,11 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver Channel: context.Channel, Target: context.Target, SourceSite: context.SourceSite, + // 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 c2cea5b..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,12 +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-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: _nodeIdentity?.NodeName, ct).ConfigureAwait(false); break; 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(_detail?.SourceNode) ? "—" : _detail.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.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor index 30ece7b..d24af8c 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(det.SourceNode) ? "—" : det.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.CentralUI/Services/AuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs index 346a8a9..6997060 100644 --- a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs +++ b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs @@ -38,6 +38,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService // and "% errors over the last hour" as the KPI definition. private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1); + // Audit Log Node filter (Task 15): the distinct-source-nodes lookup powers + // the filter dropdown population. A 60s cache keeps rendering the filter + // bar cheap — node membership changes (failover, scaling) surface within + // a minute, which is acceptable for a filter affordance. + private static readonly TimeSpan DistinctSourceNodesTtl = TimeSpan.FromSeconds(60); + // Production path: open a fresh scope per operation. Null in the test-seam ctor. private readonly IServiceScopeFactory? _scopeFactory; @@ -47,6 +53,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService private readonly ICentralHealthAggregator _healthAggregator; + // Distinct-source-nodes cache. Lock guards the (snapshot, expiry) pair so + // a stampede of concurrent filter-bar renders does not turn into N DB hits. + private readonly object _sourceNodesLock = new(); + private IReadOnlyList? _cachedSourceNodes; + private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue; + /// /// Production constructor — resolves from a /// fresh DI scope on every call so each query gets its own @@ -151,4 +163,42 @@ public sealed class AuditLogQueryService : IAuditLogQueryService var repository = scope.ServiceProvider.GetRequiredService(); return await repository.GetExecutionTreeAsync(executionId, ct); } + + /// + public async Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) + { + // Fast path: a fresh cache entry served entirely from memory. + lock (_sourceNodesLock) + { + if (_cachedSourceNodes is not null && DateTime.UtcNow < _cachedSourceNodesExpiryUtc) + { + return _cachedSourceNodes; + } + } + + IReadOnlyList snapshot; + if (_injectedRepository is not null) + { + snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct); + } + else + { + await using var scope = _scopeFactory!.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + snapshot = await repository.GetDistinctSourceNodesAsync(ct); + } + + // Slow path: replace the cache entry. Concurrent slow paths can race + // here — every winner stores a valid snapshot, so the last write wins + // and no caller sees stale data. The double-check inside the lock is + // deliberately omitted: redundant DB hits during a stampede are rare + // (60s TTL) and cheaper than holding the lock across the await. + lock (_sourceNodesLock) + { + _cachedSourceNodes = snapshot; + _cachedSourceNodesExpiryUtc = DateTime.UtcNow + DistinctSourceNodesTtl; + } + + return snapshot; + } } diff --git a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs index e802ec5..9cd0125 100644 --- a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs +++ b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs @@ -69,4 +69,15 @@ public interface IAuditLogQueryService Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default); + + /// + /// Returns the distinct, non-null SourceNode values present in the + /// central AuditLog table, backing the Audit Log page's Node + /// multi-select filter. The implementation caches the result for ~60s so + /// rendering the filter bar never produces more than one DB hit per minute + /// per circuit. The cache is process-wide — node membership changes + /// (failover, scaling) surface within a minute, which is acceptable for a + /// filter affordance. + /// + Task> GetDistinctSourceNodesAsync(CancellationToken ct = default); } 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/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/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/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/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs index 13fd96b..159eda1 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -175,4 +175,12 @@ public interface IAuditLogRepository Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default); + + /// + /// Returns the distinct, non-null SourceNode values present in the + /// AuditLog table, in ascending order. Backs the Audit Log page's + /// "Node" multi-select filter dropdown — the Central UI caches the result + /// for ~60s so the repository is hit at most once per minute per circuit. + /// + Task> GetDistinctSourceNodesAsync(CancellationToken ct = default); } 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.Commons/Messages/Audit/SiteCallQueries.cs b/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs index d5c98a4..4245e3c 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 . @@ -117,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/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/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs index 12151d2..e6b6365 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 . @@ -117,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.Commons/Types/Audit/AuditLogQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs index b042cc2..4b8ac54 100644 --- a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs +++ b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs @@ -5,16 +5,24 @@ namespace ScadaLink.Commons.Types.Audit; /// /// Filter predicate for . /// Any field left null means "do not constrain on that column". The -/// , , and -/// dimensions are multi-value: a null OR empty -/// list means "do not constrain", and a non-empty list is OR-combined within the -/// dimension (translated to a SQL IN (…)). Time bounds are half-open in -/// the spec sense — is inclusive and is -/// inclusive of the upper bound; the repository SQL uses >= / <= +/// , , , +/// and dimensions are +/// multi-value: a null OR empty list means "do not constrain", and a +/// non-empty list is OR-combined within the dimension (translated to a SQL +/// IN (…)). Time bounds are half-open in the spec sense — +/// is inclusive and is inclusive of +/// the upper bound; the repository SQL uses >= / <= /// respectively. All filter dimensions are AND-combined with one another. The /// single-value , and /// dimensions constrain on equality when set. /// +/// +/// Restrict to rows whose SourceNode matches one of the supplied node +/// identifiers (e.g. "central-a", "site-plant-a-node-a"). A null +/// or empty list means "do not constrain"; a non-empty list is translated to +/// SQL SourceNode IN (…). Rows with NULL SourceNode are excluded +/// when the filter is set (the same SourceSiteIds contract). +/// public sealed record AuditLogQueryFilter( IReadOnlyList? Channels = null, IReadOnlyList? Kinds = null, @@ -26,4 +34,5 @@ public sealed record AuditLogQueryFilter( Guid? ExecutionId = null, Guid? ParentExecutionId = null, DateTime? FromUtc = null, - DateTime? ToUtc = null); + DateTime? ToUtc = null, + IReadOnlyList? SourceNodes = null); 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.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.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.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.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/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index fbd3f1d..e092b65 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -70,6 +70,15 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.SourceNode) + .HasColumnType("varchar(64)") + .HasMaxLength(64) + .IsUnicode(false); + // Bounded unicode message column. builder.Property(e => e.ErrorMessage) .HasMaxLength(1024); @@ -97,6 +106,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..6c2c693 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -47,6 +47,16 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration n.SourceScript).HasMaxLength(200); + // 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) + .IsUnicode(false); + // 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..a0203ab 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs @@ -59,6 +59,16 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration s.LastError) .HasMaxLength(1024); + // 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) + .IsUnicode(false); + // 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..af73714 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs @@ -0,0 +1,1647 @@ +// +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) + .IsUnicode(false) + .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..0499ece --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.cs @@ -0,0 +1,60 @@ +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)", + unicode: false, + 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/20260523201950_AddNotificationSourceNode.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs new file mode 100644 index 0000000..d4ea3b8 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs @@ -0,0 +1,1652 @@ +// +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) + .IsUnicode(false) + .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) + .IsUnicode(false) + .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..2510ff6 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.cs @@ -0,0 +1,42 @@ +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)", + unicode: false, + maxLength: 64, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SourceNode", + table: "Notifications"); + } + } +} 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..fe3c141 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs @@ -0,0 +1,1657 @@ +// +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) + .IsUnicode(false) + .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) + .IsUnicode(false) + .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) + .IsUnicode(false) + .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..9dd92c2 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.cs @@ -0,0 +1,43 @@ +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)", + unicode: false, + 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 1f5a5e3..d78df93 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -113,6 +113,11 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .IsUnicode(false) .HasColumnType("varchar(128)"); + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + b.Property("SourceScript") .HasMaxLength(128) .IsUnicode(false) @@ -156,6 +161,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"); @@ -255,6 +263,11 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("RetryCount") .HasColumnType("int"); + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + b.Property("SourceSite") .IsRequired() .HasMaxLength(64) @@ -813,6 +826,11 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .HasMaxLength(200) .HasColumnType("nvarchar(200)"); + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + b.Property("SourceScript") .HasMaxLength(200) .HasColumnType("nvarchar(200)"); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 33dad3a..beb2d07 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); @@ -140,6 +140,13 @@ VALUES query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId)); } + // SourceNode filter mirrors SourceSiteIds: a non-empty list translates to + // SQL IN (…); NULL SourceNode rows are excluded when the filter is set. + if (filter.SourceNodes is { Count: > 0 } sourceNodes) + { + query = query.Where(e => e.SourceNode != null && sourceNodes.Contains(e.SourceNode)); + } + if (!string.IsNullOrEmpty(filter.Target)) { var target = filter.Target; @@ -273,13 +280,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]; @@ -760,6 +768,25 @@ VALUES } } + /// + /// Distinct non-null SourceNode values for the Audit Log page's + /// Node filter dropdown. EF Core translates this to + /// SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL ORDER BY SourceNode + /// — a single index-less scan, but the column is bounded (one entry per + /// node in the cluster, currently <10) and the Central UI caches the + /// result for ~60s, so a periodic scan is acceptable. + /// + public async Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) + { + return await _context.Set() + .AsNoTracking() + .Where(e => e.SourceNode != null) + .Select(e => e.SourceNode!) + .Distinct() + .OrderBy(n => n) + .ToListAsync(ct); + } + /// /// Splits a STRING_AGG comma-joined value into a distinct, ordered /// list. A null/empty aggregate (a stub node with no rows) yields an empty 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.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index d90d0d9..ca3c226 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 @@ -171,13 +192,19 @@ 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, 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}) 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.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/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index 2c3284d..8fc6956 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( @@ -747,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); @@ -957,6 +960,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.SiteCallAudit/SiteCallAuditActor.cs b/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs index 8078060..cfc29ce 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) @@ -650,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/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 a8c68fb..55e781f 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. @@ -291,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. @@ -315,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. @@ -335,7 +362,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. @@ -431,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. @@ -452,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; @@ -463,6 +504,7 @@ public class ScriptRuntimeContext _sourceScript = sourceScript; _cachedForwarder = cachedForwarder; _parentExecutionId = parentExecutionId; + _sourceNode = sourceNode; } public async Task Call( @@ -648,6 +690,11 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, + // 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, @@ -766,6 +813,11 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, + // 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. @@ -833,6 +885,11 @@ public class ScriptRuntimeContext Channel: "ApiOutbound", Target: target, SourceSite: _siteId, + // 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, @@ -1089,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 @@ -1102,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; @@ -1113,6 +1180,7 @@ public class ScriptRuntimeContext _sourceScript = sourceScript; _cachedForwarder = cachedForwarder; _parentExecutionId = parentExecutionId; + _sourceNode = sourceNode; } public async Task Connection( @@ -1243,6 +1311,11 @@ public class ScriptRuntimeContext Channel: "DbOutbound", Target: target, SourceSite: _siteId, + // 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, @@ -1316,6 +1389,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. @@ -1329,7 +1410,8 @@ public class ScriptRuntimeContext ILogger logger, Guid executionId, IAuditWriter? auditWriter = null, - Guid? parentExecutionId = null) + Guid? parentExecutionId = null, + string? sourceNode = null) { _storeAndForward = storeAndForward; _siteCommunicationActor = siteCommunicationActor; @@ -1341,6 +1423,7 @@ public class ScriptRuntimeContext _executionId = executionId; _auditWriter = auditWriter; _parentExecutionId = parentExecutionId; + _sourceNode = sourceNode; } /// @@ -1358,7 +1441,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); } /// @@ -1455,6 +1541,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, @@ -1464,7 +1559,8 @@ public class ScriptRuntimeContext ILogger logger, Guid executionId, IAuditWriter? auditWriter = null, - Guid? parentExecutionId = null) + Guid? parentExecutionId = null, + string? sourceNode = null) { _listName = listName; _storeAndForward = storeAndForward; @@ -1475,6 +1571,7 @@ public class ScriptRuntimeContext _executionId = executionId; _auditWriter = auditWriter; _parentExecutionId = parentExecutionId; + _sourceNode = sourceNode; } /// @@ -1527,7 +1624,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/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/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/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs index 32a6606..094e4b4 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -228,5 +228,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default) => _inner.GetExecutionTreeAsync(executionId, ct); + + public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => + _inner.GetDistinctSourceNodesAsync(ct); } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs index 290ff16..aaaa6b6 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs @@ -86,6 +86,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + + public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } private IServiceProvider BuildScopedProvider(IAuditLogRepository repo) diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs index 4ad28a8..bad4feb 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs @@ -55,6 +55,9 @@ public class CentralAuditWriteFailuresTests : TestKit public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + + public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } /// diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs index 1a9ca3b..3901f06 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,89 @@ 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()); + } + + [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()); + } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs index f8b3b49..447feab 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs @@ -101,6 +101,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + + public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } /// 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.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/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.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 8f40ebe..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); } @@ -43,9 +45,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 +61,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 +80,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() { @@ -187,6 +202,7 @@ public class SqliteAuditWriterSchemaTests return new SqliteAuditWriter( Options.Create(options), NullLogger.Instance, + new FakeNodeIdentityProvider(), connectionStringOverride: $"Data Source={dataSource};Cache=Shared"); } @@ -377,4 +393,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); + } + } } 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/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 61cc353..e5de6d5 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallTelemetryForwarderTests.cs @@ -51,6 +51,7 @@ public class CachedCallTelemetryForwarderTests Channel: "ApiOutbound", Target: "ERP.GetOrder", SourceSite: "site-1", + SourceNode: null, Status: "Submitted", RetryCount: 0, LastError: null, @@ -80,6 +81,7 @@ public class CachedCallTelemetryForwarderTests Channel: "ApiOutbound", Target: "ERP.GetOrder", SourceSite: "site-1", + SourceNode: null, Status: "Attempted", RetryCount: retryCount, LastError: lastError, @@ -107,6 +109,7 @@ public class CachedCallTelemetryForwarderTests Channel: "ApiOutbound", Target: "ERP.GetOrder", SourceSite: "site-1", + SourceNode: null, Status: status, RetryCount: 2, LastError: null, @@ -132,12 +135,18 @@ public class CachedCallTelemetryForwarderTests Arg.Any()); // Tracking row: insert-if-not-exists with kind discriminator. + // 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", "ERP.GetOrder", "inst-1", "ScriptActor:doStuff", + null, Arg.Any()); await _tracking.DidNotReceiveWithAnyArgs().RecordAttemptAsync( default, default!, default, default, default, default); @@ -163,7 +172,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); } @@ -186,7 +195,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); } @@ -210,6 +219,7 @@ public class CachedCallTelemetryForwarderTests Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); } @@ -222,6 +232,7 @@ public class CachedCallTelemetryForwarderTests Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) .Throws(new InvalidOperationException("sqlite locked")); @@ -242,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.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.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs index d34fa3d..26617c5 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs @@ -3,8 +3,11 @@ using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ScadaLink.CentralUI.Components.Audit; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Enums; @@ -29,6 +32,7 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit; public class AuditFilterBarTests : BunitContext { private readonly ISiteRepository _siteRepo; + private readonly IAuditLogQueryService _auditLogQueryService; public AuditFilterBarTests() { @@ -40,6 +44,17 @@ public class AuditFilterBarTests : BunitContext new("Plant B", "plant-b") { Id = 2 }, })); Services.AddSingleton(_siteRepo); + + // Task 15: the Node multi-select pulls its options from + // IAuditLogQueryService.GetDistinctSourceNodesAsync. The default stub + // returns the two central nodes the cluster uses; individual tests can + // override via _auditLogQueryService.GetDistinctSourceNodesAsync(...).Returns(...). + _auditLogQueryService = Substitute.For(); + _auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any()) + .Returns(Task.FromResult>(new[] { "central-a", "central-b" })); + _auditLogQueryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + Services.AddSingleton(_auditLogQueryService); } [Fact] @@ -55,6 +70,7 @@ public class AuditFilterBarTests : BunitContext "data-test=\"filter-kind\"", "data-test=\"filter-status\"", "data-test=\"filter-site\"", + "data-test=\"filter-node\"", "data-test=\"filter-time-range\"", "data-test=\"filter-custom-range\"", "data-test=\"filter-instance\"", @@ -160,6 +176,30 @@ public class AuditFilterBarTests : BunitContext Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses); } + [Fact] + public void NodeMultiSelect_RendersOptions_FromQueryService_AndMapsThroughToFilter() + { + // Task 15: the Node filter pulls its option set from + // IAuditLogQueryService.GetDistinctSourceNodesAsync and threads the + // chip selection into AuditLogQueryFilter.SourceNodes. + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + // The bar marker plus the option checkboxes for the two cluster nodes + // are present after init (the constructor stubs return two nodes). + Assert.Contains("data-test=\"filter-node\"", cut.Markup); + Assert.Contains("data-test=\"filter-node-ms-opt-central-a\"", cut.Markup); + Assert.Contains("data-test=\"filter-node-ms-opt-central-b\"", cut.Markup); + + cut.Find("[data-test=\"filter-node-ms-opt-central-a\"]").Change(true); + cut.Find("[data-test=\"filter-apply\"]").Click(); + + Assert.NotNull(captured); + Assert.NotNull(captured!.SourceNodes); + Assert.Equal(new[] { "central-a" }, captured.SourceNodes); + } + [Fact] public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs index a0549b2..e6dd183 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs @@ -123,6 +123,28 @@ public class AuditResultsGridTests : BunitContext Assert.Equal(target.EventId, captured!.EventId); } + [Fact] + public void Render_IncludesNodeColumn_BetweenSiteAndChannel() + { + // Task 15: the grid surfaces SourceNode in a dedicated "Node" column + // positioned between Site and Channel. + StubPage(new List + { + MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered), + }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + Assert.Contains("data-test=\"col-header-Node\"", cut.Markup); + + // The header order must place Node between Site and Channel. + var siteIdx = cut.Markup.IndexOf("data-test=\"col-header-Site\"", StringComparison.Ordinal); + var nodeIdx = cut.Markup.IndexOf("data-test=\"col-header-Node\"", StringComparison.Ordinal); + var channelIdx = cut.Markup.IndexOf("data-test=\"col-header-Channel\"", StringComparison.Ordinal); + Assert.True(siteIdx < nodeIdx, "Node column must follow Site."); + Assert.True(nodeIdx < channelIdx, "Node column must precede Channel."); + } + [Fact] public void Render_IncludesExecutionIdColumn() { diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs index f947dd9..f4902a1 100644 --- a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs @@ -282,6 +282,70 @@ public class AuditLogQueryServiceTests Assert.NotSame(resolvedRepos[0], resolvedRepos[1]); } + // ───────────────────────────────────────────────────────────────────────── + // Task 15: SourceNode filter forwarding + distinct-nodes service contract. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task QueryAsync_ForwardsSourceNodesFilter_ToRepository() + { + // The Audit Log page's new Node multi-select pushes its chip set into + // AuditLogQueryFilter.SourceNodes; the service must thread it through + // unchanged so the repository's IN-list applies. + var repo = Substitute.For(); + var filter = new AuditLogQueryFilter( + SourceNodes: new[] { "central-a", "site-plant-a-node-a" }); + repo.QueryAsync(filter, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + var sut = new AuditLogQueryService(repo, EmptyAggregator()); + await sut.QueryAsync(filter); + + await repo.Received(1).QueryAsync( + Arg.Is(f => + f.SourceNodes != null + && f.SourceNodes.Count == 2 + && f.SourceNodes.Contains("central-a") + && f.SourceNodes.Contains("site-plant-a-node-a")), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall() + { + var repo = Substitute.For(); + var expected = new[] { "central-a", "central-b", "site-plant-a-node-a" }; + repo.GetDistinctSourceNodesAsync(Arg.Any()) + .Returns(Task.FromResult>(expected)); + + var sut = new AuditLogQueryService(repo, EmptyAggregator()); + + var result = await sut.GetDistinctSourceNodesAsync(); + + Assert.Equal(expected, result); + await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any()); + } + + [Fact] + public async Task GetDistinctSourceNodesAsync_CachesSnapshot_AcrossRepeatedCalls() + { + // Two back-to-back calls within the 60s TTL must hit the repository + // exactly once — the filter bar should never produce N DB hits when + // the operator opens it twice in quick succession. + var repo = Substitute.For(); + repo.GetDistinctSourceNodesAsync(Arg.Any()) + .Returns(Task.FromResult>(new[] { "central-a" })); + + var sut = new AuditLogQueryService(repo, EmptyAggregator()); + + var first = await sut.GetDistinctSourceNodesAsync(); + var second = await sut.GetDistinctSourceNodesAsync(); + + Assert.Equal(first, second); + await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any()); + } + private static SiteHealthState StateWithBacklog(string siteId, int? pending) { SiteAuditBacklogSnapshot? backlog = pending.HasValue 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() { diff --git a/tests/ScadaLink.Commons.Tests/Entities/Audit/SiteCallTests.cs b/tests/ScadaLink.Commons.Tests/Entities/Audit/SiteCallTests.cs new file mode 100644 index 0000000..b45deff --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Entities/Audit/SiteCallTests.cs @@ -0,0 +1,40 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types; + +namespace ScadaLink.Commons.Tests.Entities.Audit; + +/// +/// 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/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/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/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() { 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); + } +} 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(), 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))!; + } +} 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))!; + } +} 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))!; + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index 1d7e337..ae1c72a 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() { @@ -247,6 +297,91 @@ public class AuditLogRepositoryTests : IClassFixture Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId)); } + // ────────────────────────────────────────────────────────────────────── + // Task 15: SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new + // Node multi-select contract — non-empty list → SQL IN (…); NULL + // SourceNode rows are excluded when the filter is set; the distinct + // enumeration omits nulls and orders ascending. + // ────────────────────────────────────────────────────────────────────── + + [SkippableFact] + public async Task QueryAsync_FilterBySourceNode_ReturnsMatchingRows() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var t0 = new DateTime(2026, 5, 4, 10, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a")); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b")); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: null)); + + var rows = await repo.QueryAsync( + new AuditLogQueryFilter( + SourceSiteIds: new[] { siteId }, + SourceNodes: new[] { "central-a" }), + new AuditLogPaging(PageSize: 10)); + + Assert.Single(rows); + Assert.Equal("central-a", rows[0].SourceNode); + } + + [SkippableFact] + public async Task QueryAsync_FilterByMultipleSourceNodes_ReturnsUnion() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var t0 = new DateTime(2026, 5, 4, 11, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a")); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b")); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "site-plant-a-node-a")); + + var rows = await repo.QueryAsync( + new AuditLogQueryFilter( + SourceSiteIds: new[] { siteId }, + SourceNodes: new[] { "central-a", "central-b" }), + new AuditLogPaging(PageSize: 10)); + + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Contains(r.SourceNode, new[] { "central-a", "central-b" })); + } + + [SkippableFact] + public async Task GetDistinctSourceNodesAsync_ReturnsDistinctNonNullValues_Ordered() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var t0 = new DateTime(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-b")); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-a")); + // Duplicate of "central-a" — must collapse to one entry. + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "central-a")); + // Null row — must be excluded entirely. + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), sourceNode: null)); + + var nodes = await repo.GetDistinctSourceNodesAsync(); + + // The whole table is scanned (no per-test scoping on this query), so the + // assertion is "our seeded values appear once each, in order" rather + // than a strict equality on the full result. + Assert.Contains("central-a", nodes); + Assert.Contains("central-b", nodes); + // No null entry — the WHERE SourceNode IS NOT NULL clause drops them. + Assert.DoesNotContain(nodes, n => n is null); + // Ordered ascending. + Assert.Equal(nodes.OrderBy(n => n, StringComparer.Ordinal), nodes); + } + [SkippableFact] public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows() { @@ -962,7 +1097,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 +1107,7 @@ public class AuditLogRepositoryTests : IClassFixture Kind = kind, Status = status, SourceSiteId = siteId, + SourceNode = sourceNode, ErrorMessage = errorMessage, ExecutionId = executionId, ParentExecutionId = parentExecutionId, 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.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); + } +} diff --git a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs index be01f04..82cafd0 100644 --- a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs +++ b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs @@ -100,6 +100,9 @@ public class SiteAuditPushFlowTests : TestKit public Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default) => throw new NotSupportedException(); + + public Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) + => throw new NotSupportedException(); } private static AuditEvent NewPendingEvent(Guid id) => new() @@ -146,8 +149,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. 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.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs index 9c2860b..4f71714 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() { @@ -315,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); @@ -338,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 3c39257..d181413 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(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() { @@ -363,7 +402,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/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); + } } 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); + } } 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() { 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);