Merge branch 'feature/audit-source-node'

End-to-end SourceNode audit stamping across AuditLog, Notifications, and
SiteCalls — captures the cluster node (node-a/node-b for site rows,
central-a/central-b for central direct-write rows) that produced each
audit row. 23 commits, 2159 tests passing across 9 affected projects,
live-smoke verified against the running cluster.

Per 'docs/plans/2026-05-23-audit-source-node.md'.
This commit is contained in:
Joseph Doherty
2026-05-23 18:50:50 -04:00
120 changed files with 9086 additions and 168 deletions

View File

@@ -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

View File

@@ -2,6 +2,7 @@
"ScadaLink": {
"Node": {
"Role": "Central",
"NodeName": "central-a",
"NodeHostname": "scadalink-central-a",
"RemotingPort": 8081
},

View File

@@ -2,6 +2,7 @@
"ScadaLink": {
"Node": {
"Role": "Central",
"NodeName": "central-b",
"NodeHostname": "scadalink-central-b",
"RemotingPort": 8081
},

View File

@@ -2,6 +2,7 @@
"ScadaLink": {
"Node": {
"Role": "Site",
"NodeName": "node-a",
"NodeHostname": "scadalink-site-a-a",
"SiteId": "site-a",
"RemotingPort": 8082,

View File

@@ -2,6 +2,7 @@
"ScadaLink": {
"Node": {
"Role": "Site",
"NodeName": "node-b",
"NodeHostname": "scadalink-site-a-b",
"SiteId": "site-a",
"RemotingPort": 8082,

View File

@@ -2,6 +2,7 @@
"ScadaLink": {
"Node": {
"Role": "Site",
"NodeName": "node-a",
"NodeHostname": "scadalink-site-b-a",
"SiteId": "site-b",
"RemotingPort": 8082,

View File

@@ -2,6 +2,7 @@
"ScadaLink": {
"Node": {
"Role": "Site",
"NodeName": "node-b",
"NodeHostname": "scadalink-site-b-b",
"SiteId": "site-b",
"RemotingPort": 8082,

View File

@@ -2,6 +2,7 @@
"ScadaLink": {
"Node": {
"Role": "Site",
"NodeName": "node-a",
"NodeHostname": "scadalink-site-c-a",
"SiteId": "site-c",
"RemotingPort": 8082,

View File

@@ -2,6 +2,7 @@
"ScadaLink": {
"Node": {
"Role": "Site",
"NodeName": "node-b",
"NodeHostname": "scadalink-site-c-b",
"SiteId": "site-c",
"RemotingPort": 8082,

View File

@@ -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 <project> --filter <name>`) 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/<project> --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
{
/// <summary>
/// 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).
/// </summary>
string? NodeName { get; }
}
```
```csharp
// src/ScadaLink.Host/NodeIdentityProvider.cs
internal sealed class NodeIdentityProvider : INodeIdentityProvider
{
public NodeIdentityProvider(IOptions<NodeOptions> 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<INodeIdentityProvider, NodeIdentityProvider>();
```
**Step 5: Run tests + build**
```bash
dotnet test tests/<project> --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/<project>/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 \
<updated callers>
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/<modified>.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/<sitecall mapper 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/<ts>_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<string>(
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/<ts>_AddAuditLogSourceNode.cs \
src/ScadaLink.ConfigurationDatabase/Migrations/<ts>_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/<ts>_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 14:** 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 14:** 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<INodeIdentityProvider>();
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<INodeIdentityProvider>();
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<NotificationSubmitAck>(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<string>? 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": "<value>"` 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.

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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. |

View File

@@ -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

View File

@@ -43,6 +43,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
private readonly ILogger<CentralAuditWriter> _logger;
private readonly IAuditPayloadFilter? _filter;
private readonly ICentralAuditWriteFailureCounter _failureCounter;
private readonly INodeIdentityProvider? _nodeIdentity;
/// <summary>
/// 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
/// <c>CentralAuditWriteFailures</c> 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
/// <see cref="INodeIdentityProvider"/> 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).
/// </summary>
public CentralAuditWriter(
IServiceProvider services,
ILogger<CentralAuditWriter> 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;
}
/// <summary>
@@ -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<IAuditLogRepository>();
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})",

View File

@@ -146,7 +146,17 @@ public static class ServiceCollectionExtensions
new CachedCallTelemetryForwarder(
sp.GetRequiredService<IAuditWriter>(),
sp.GetService<ScadaLink.Commons.Interfaces.IOperationTrackingStore>(),
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>()));
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>(),
// 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<INodeIdentityProvider>()));
// 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<CachedCallLifecycleBridge>();
// 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<CachedCallLifecycleBridge>(sp => new CachedCallLifecycleBridge(
sp.GetRequiredService<ICachedCallTelemetryForwarder>(),
sp.GetRequiredService<ILogger<CachedCallLifecycleBridge>>(),
sp.GetService<INodeIdentityProvider>()));
services.AddSingleton<ICachedCallLifecycleObserver>(
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
@@ -183,7 +205,14 @@ public static class ServiceCollectionExtensions
sp,
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
sp.GetRequiredService<IAuditPayloadFilter>(),
sp.GetRequiredService<ICentralAuditWriteFailureCounter>()));
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
// 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<INodeIdentityProvider>()));
return services;
}

View File

@@ -42,6 +42,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private readonly SqliteConnection _connection;
private readonly SqliteAuditWriterOptions _options;
private readonly ILogger<SqliteAuditWriter> _logger;
private readonly INodeIdentityProvider _nodeIdentity;
private readonly object _writeLock = new();
private readonly Channel<PendingAuditEvent> _writeQueue;
private readonly Task _writerLoop;
@@ -50,13 +51,16 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
public SqliteAuditWriter(
IOptions<SqliteAuditWriterOptions> options,
ILogger<SqliteAuditWriter> 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");
}
/// <summary>
@@ -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<AuditKind>(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<AuditStatus>(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<AuditForwardState>(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<AuditStatus>(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<AuditForwardState>(reader.GetString(20)),
ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
};
}

View File

@@ -39,12 +39,23 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
private readonly ICachedCallTelemetryForwarder _forwarder;
private readonly ILogger<CachedCallLifecycleBridge> _logger;
/// <summary>
/// SourceNode-stamping (Task 14): the local node identity provider used to
/// stamp <c>SiteCallOperational.SourceNode</c> 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 <c>SiteCalls</c> row with SourceNode NULL.
/// </summary>
private readonly INodeIdentityProvider? _nodeIdentity;
public CachedCallLifecycleBridge(
ICachedCallTelemetryForwarder forwarder,
ILogger<CachedCallLifecycleBridge> logger)
ILogger<CachedCallLifecycleBridge> logger,
INodeIdentityProvider? nodeIdentity = null)
{
_forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_nodeIdentity = nodeIdentity;
}
/// <inheritdoc/>
@@ -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,

View File

@@ -53,6 +53,14 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
private readonly IOperationTrackingStore? _trackingStore;
private readonly ILogger<CachedCallTelemetryForwarder> _logger;
/// <summary>
/// SourceNode-stamping (Task 14): local node identity provider used to
/// stamp the tracking-store row's <c>SourceNode</c> column on
/// <c>RecordEnqueueAsync</c>. Optional — when null (legacy / test hosts)
/// the column stays NULL on the tracking row.
/// </summary>
private readonly INodeIdentityProvider? _nodeIdentity;
/// <summary>
/// Construct the forwarder. <paramref name="trackingStore"/> 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<CachedCallTelemetryForwarder> logger)
ILogger<CachedCallTelemetryForwarder> logger,
INodeIdentityProvider? nodeIdentity = null)
{
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_trackingStore = trackingStore;
_nodeIdentity = nodeIdentity;
}
/// <summary>
@@ -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;

View File

@@ -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
<div class="card mb-3" data-test="audit-filter-bar">
<div class="card-body py-2">
@@ -58,6 +60,21 @@
</div>
</div>
@* 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. *@
<div class="col-auto" data-test="filter-node">
<label class="form-label small mb-1">Node</label>
<div>
<MultiSelectDropdown TValue="string"
Items="_sourceNodes"
Selected="_model.SourceNodes"
EmptyText="No nodes available"
DataTest="filter-node-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-time-range">
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
<select id="audit-time-range" class="form-select form-select-sm"

View File

@@ -31,6 +31,15 @@ public partial class AuditFilterBar
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
/// <summary>
/// Distinct <c>SourceNode</c> identifiers in display order; populated once
/// when the filter bar initialises from the cached
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService.GetDistinctSourceNodesAsync"/>
/// snapshot (60s TTL). Failure is non-fatal — the dropdown falls back to
/// "No nodes available", mirroring the site loader.
/// </summary>
private IReadOnlyList<string> _sourceNodes = Array.Empty<string>();
/// <summary>
/// Raised when the user clicks Apply. Carries the
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
@@ -80,6 +89,20 @@ public partial class AuditFilterBar
}
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
// Populate the Node dropdown alongside the Site dropdown. The service
// caches the distinct-nodes lookup for 60s so this never costs more
// than one DB hit per minute per circuit; on failure the dropdown
// degrades to "No nodes available" like the site loader.
try
{
var nodes = await AuditLogQueryService.GetDistinctSourceNodesAsync();
_sourceNodes = nodes.ToArray();
}
catch
{
_sourceNodes = Array.Empty<string>();
}
}
/// <summary>
@@ -128,6 +151,7 @@ public partial class AuditFilterBar
_model.Kinds.Clear();
_model.Statuses.Clear();
_model.SiteIdentifiers.Clear();
_model.SourceNodes.Clear();
_model.TimeRange = AuditTimeRangePreset.LastHour;
_model.CustomFromUtc = null;
_model.CustomToUtc = null;

View File

@@ -38,6 +38,14 @@ public sealed class AuditQueryModel
public HashSet<AuditStatus> Statuses { get; } = new();
public HashSet<string> SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Selected source-node identifiers (e.g. <c>"central-a"</c>,
/// <c>"site-plant-a-node-a"</c>). Mirrors <see cref="SiteIdentifiers"/> —
/// chip multi-select state, empty = "do not constrain", mapped through to
/// <see cref="AuditLogQueryFilter.SourceNodes"/> by <see cref="ToFilter"/>.
/// </summary>
public HashSet<string> SourceNodes { get; } = new(StringComparer.OrdinalIgnoreCase);
public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour;
public DateTime? CustomFromUtc { get; set; }
public DateTime? CustomToUtc { get; set; }
@@ -153,7 +161,8 @@ public sealed class AuditQueryModel
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc,
ToUtc: toUtc);
ToUtc: toUtc,
SourceNodes: SourceNodes.Count > 0 ? SourceNodes.ToArray() : null);
}
/// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>

View File

@@ -105,6 +105,9 @@
case "Site":
<span class="small">@(row.SourceSiteId ?? "—")</span>
break;
case "Node":
<span class="small">@(row.SourceNode ?? "—")</span>
break;
case "Channel":
<span class="small">@row.Channel</span>
break;

View File

@@ -118,6 +118,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
{
("OccurredAtUtc", "OccurredAtUtc"),
("Site", "Site"),
("Node", "Node"),
("Channel", "Channel"),
("Kind", "Kind"),
("Status", "Status"),

View File

@@ -60,6 +60,16 @@
<input id="no-list" type="text" class="form-control form-control-sm"
style="min-width: 140px;" placeholder="Any" @bind="_listFilter" />
</div>
@* Task 16: free-text Node filter — exact match against the
notification's SourceNode column. Sites + central nodes
both flow through this single input. *@
<div class="col-auto">
<label class="form-label small mb-1" for="no-node">Node</label>
<input id="no-node" type="text" class="form-control form-control-sm"
style="min-width: 140px;" placeholder="Any"
data-test="notif-filter-node"
@bind="_nodeFilter" />
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="no-from">From</label>
<input id="no-from" type="datetime-local" class="form-control form-control-sm"
@@ -131,6 +141,7 @@
<th>Status</th>
<th class="text-end">Retries</th>
<th>Source site</th>
<th>Node</th>
<th>Created</th>
<th>Delivered</th>
<th class="text-end">Actions</th>
@@ -162,6 +173,7 @@
</td>
<td class="text-end font-monospace">@n.RetryCount</td>
<td><span class="small">@SiteName(n.SourceSiteId)</span></td>
<td><span class="small">@(n.SourceNode ?? "—")</span></td>
<td><TimestampDisplay Value="@n.CreatedAt" Format="yyyy-MM-dd HH:mm" /></td>
<td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td>
<td class="text-end" @ondblclick:stopPropagation="true">
@@ -253,6 +265,9 @@
<dt class="col-sm-3">Source site</dt>
<dd class="col-sm-9">@SiteName(d.SourceSiteId)</dd>
<dt class="col-sm-3">Source node</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(_detail?.SourceNode) ? "—" : _detail.SourceNode)</dd>
<dt class="col-sm-3">Source instance</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)</dd>
@@ -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;

View File

@@ -58,6 +58,17 @@
}
</select>
</div>
@* 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"). *@
<div class="col-auto">
<label class="form-label small mb-1" for="sc-node">Node</label>
<input id="sc-node" type="text" class="form-control form-control-sm"
style="min-width: 150px;" placeholder="Any"
data-test="site-calls-filter-node"
@bind="_nodeFilter" />
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-from">From</label>
<input id="sc-from" type="datetime-local" class="form-control form-control-sm"
@@ -125,6 +136,7 @@
<tr>
<th>Tracked operation</th>
<th>Source site</th>
<th>Node</th>
<th>Channel</th>
<th>Target</th>
<th>Status</th>
@@ -143,6 +155,7 @@
title="Double-click for full detail">
<td><code class="small" title="@c.TrackedOperationId">@ShortId(c.TrackedOperationId)</code></td>
<td><span class="small">@SiteName(c.SourceSite)</span></td>
<td><span class="small">@(c.SourceNode ?? "—")</span></td>
<td>@c.Channel</td>
<td>@c.Target</td>
<td>
@@ -253,6 +266,9 @@
<dt class="col-sm-3">Source site</dt>
<dd class="col-sm-9">@SiteName(det.SourceSite)</dd>
<dt class="col-sm-3">Source node</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(det.SourceNode) ? "—" : det.SourceNode)</dd>
<dt class="col-sm-3">Channel</dt>
<dd class="col-sm-9">@det.Channel</dd>

View File

@@ -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;

View File

@@ -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<string>? _cachedSourceNodes;
private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue;
/// <summary>
/// Production constructor — resolves <see cref="IAuditLogRepository"/> 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<IAuditLogRepository>();
return await repository.GetExecutionTreeAsync(executionId, ct);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<string>> 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<string> snapshot;
if (_injectedRepository is not null)
{
snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct);
}
else
{
await using var scope = _scopeFactory!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
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;
}
}

View File

@@ -69,4 +69,15 @@ public interface IAuditLogQueryService
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
/// <summary>
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
/// central <c>AuditLog</c> 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.
/// </summary>
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
}

View File

@@ -43,6 +43,15 @@ public sealed record AuditEvent
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }
/// <summary>
/// The cluster node on which the event was emitted — `node-a` / `node-b` for
/// site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
/// for central-originated rows. Stamped by the writing node from
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that
/// has since been retired don't block ingest.
/// </summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated, when applicable.</summary>
public string? SourceInstanceId { get; init; }

View File

@@ -30,6 +30,15 @@ public sealed record SiteCall
/// <summary>Site id that submitted the cached call.</summary>
public required string SourceSite { get; init; }
/// <summary>
/// The cluster node on which the cached call was emitted — <c>node-a</c> /
/// <c>node-b</c> for site rows (qualified by <see cref="SourceSite"/>),
/// <c>central-a</c> / <c>central-b</c> for central-originated rows. Stamped
/// by the emitting node from <c>INodeIdentityProvider</c>; nullable so
/// reconciled rows from a node that has since been retired don't block ingest.
/// </summary>
public string? SourceNode { get; init; }
/// <summary>
/// Lifecycle status — string form of
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>. Monotonic: later rank

View File

@@ -25,6 +25,15 @@ public class Notification
/// <summary>Resolved delivery targets snapshotted at delivery time, for audit.</summary>
public string? ResolvedTargets { get; set; }
public string SourceSiteId { get; set; }
/// <summary>
/// The cluster node on which the notification was emitted — `node-a` / `node-b`
/// for site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
/// for central-originated rows. Carried from the site on the
/// <see cref="Commons.Messages.Notification.NotificationSubmit"/> and persisted at
/// central; nullable so rows submitted before the column existed don't block ingest.
/// </summary>
public string? SourceNode { get; set; }
public string? SourceInstanceId { get; set; }
public string? SourceScript { get; set; }

View File

@@ -40,6 +40,7 @@ public interface IOperationTrackingStore
string? targetSummary,
string? sourceInstanceId,
string? sourceScript,
string? sourceNode,
CancellationToken ct = default);
/// <summary>

View File

@@ -175,4 +175,12 @@ public interface IAuditLogRepository
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
/// <summary>
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
/// <c>AuditLog</c> 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.
/// </summary>
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,22 @@
namespace ScadaLink.Commons.Interfaces.Services;
/// <summary>
/// Surfaces the local node's semantic role-within-cluster name so downstream
/// audit writers can stamp it on the SourceNode column.
/// </summary>
/// <remarks>
/// Conventional values follow the pattern <c>node-a</c>/<c>node-b</c> on site
/// nodes and <c>central-a</c>/<c>central-b</c> 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 <c>null</c> so audit writers can persist NULL rather than an empty
/// string.
/// </remarks>
public interface INodeIdentityProvider
{
/// <summary>
/// The configured semantic node name, trimmed of surrounding whitespace.
/// <c>null</c> when unconfigured.
/// </summary>
string? NodeName { get; }
}

View File

@@ -33,7 +33,8 @@ public sealed record SiteCallQueryRequest(
DateTime? ToUtc,
DateTime? AfterCreatedAtUtc,
Guid? AfterId,
int PageSize);
int PageSize,
string? SourceNodeFilter = null);
/// <summary>
/// A single <c>SiteCalls</c> 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);
/// <summary>
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.
@@ -117,7 +119,8 @@ public sealed record SiteCallDetail(
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
DateTime? TerminalAtUtc,
DateTime IngestedAtUtc);
DateTime IngestedAtUtc,
string? SourceNode = null);
/// <summary>
/// Site Calls UI -> Central: request for the global <c>SiteCalls</c> KPI summary.

View File

@@ -18,6 +18,15 @@ namespace ScadaLink.Commons.Messages.Notification;
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
/// before the field existed, or for non-routed runs.
/// </param>
/// <param name="SourceNode">
/// 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 <c>INodeIdentityProvider</c> and carried, inside the serialized
/// payload, through the site store-and-forward buffer so the central dispatcher can
/// persist it on the <c>Notifications</c> row and echo it onto the <c>NotifyDeliver</c>
/// audit rows. Additive trailing member — null for messages built before the field
/// existed.
/// </param>
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);
/// <summary>
/// Central -> Site: ack sent after the notification row is persisted.

View File

@@ -17,7 +17,8 @@ public record NotificationOutboxQueryRequest(
DateTimeOffset? From,
DateTimeOffset? To,
int PageNumber,
int PageSize);
int PageSize,
string? SourceNodeFilter = null);
/// <summary>
/// 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);
/// <summary>
/// Central -> Outbox UI: paginated response for a <see cref="NotificationOutboxQueryRequest"/>.
@@ -117,7 +119,8 @@ public record NotificationDetail(
DateTimeOffset CreatedAt,
DateTimeOffset? LastAttemptAt,
DateTimeOffset? NextAttemptAt,
DateTimeOffset? DeliveredAt);
DateTimeOffset? DeliveredAt,
string? SourceNode = null);
/// <summary>
/// Outbox UI -> Central: request for the notification outbox KPI summary.

View File

@@ -5,16 +5,24 @@ namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
/// Any field left <c>null</c> means "do not constrain on that column". The
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
/// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
/// list means "do not constrain", and a non-empty list is OR-combined within the
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
/// inclusive of the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/>,
/// <see cref="SourceSiteIds"/> and <see cref="SourceNodes"/> dimensions are
/// multi-value: a <c>null</c> OR empty list means "do not constrain", and a
/// non-empty list is OR-combined within the dimension (translated to a SQL
/// <c>IN (…)</c>). Time bounds are half-open in the spec sense —
/// <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is inclusive of
/// the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// respectively. All filter dimensions are AND-combined with one another. The
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
/// </summary>
/// <param name="SourceNodes">
/// Restrict to rows whose <c>SourceNode</c> matches one of the supplied node
/// identifiers (e.g. <c>"central-a"</c>, <c>"site-plant-a-node-a"</c>). A null
/// or empty list means "do not constrain"; a non-empty list is translated to
/// SQL <c>SourceNode IN (…)</c>. Rows with NULL <c>SourceNode</c> are excluded
/// when the filter is set (the same SourceSiteIds contract).
/// </param>
public sealed record AuditLogQueryFilter(
IReadOnlyList<AuditChannel>? Channels = null,
IReadOnlyList<AuditKind>? 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<string>? SourceNodes = null);

View File

@@ -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.
/// </param>
/// <param name="SourceNode">
/// Restrict to cached calls originating at a specific cluster node (e.g.
/// <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c> means "do not
/// constrain". Rows with NULL <c>SourceNode</c> are excluded when set.
/// </param>
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);

View File

@@ -18,6 +18,11 @@ namespace ScadaLink.Commons.Types.Notifications;
/// <param name="StuckCutoff">Rows with <c>CreatedAt</c> older than this count as stuck.</param>
/// <param name="From">Inclusive lower bound on <c>CreatedAt</c>.</param>
/// <param name="To">Inclusive upper bound on <c>CreatedAt</c>.</param>
/// <param name="SourceNode">
/// Restrict to notifications originating at a specific cluster node (e.g.
/// <c>"central-a"</c>, <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c>
/// means "do not constrain".
/// </param>
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);

View File

@@ -21,6 +21,13 @@ namespace ScadaLink.Commons.Types;
/// </param>
/// <param name="Target">Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</param>
/// <param name="SourceSite">Site id that submitted the cached call.</param>
/// <param name="SourceNode">
/// The cluster node on which the cached call was emitted — <c>node-a</c> / <c>node-b</c>
/// for site rows (qualified by <paramref name="SourceSite"/>), <c>central-a</c> /
/// <c>central-b</c> for central-originated rows. Stamped by the emitting node from
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that has since
/// been retired don't block ingest.
/// </param>
/// <param name="Status">
/// Lifecycle status — string form of <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>:
/// <c>Submitted</c>, <c>Retrying</c>, <c>Attempted</c>, <c>Delivered</c>,
@@ -37,6 +44,7 @@ public sealed record SiteCallOperational(
string Channel,
string Target,
string SourceSite,
string? SourceNode,
string Status,
int RetryCount,
string? LastError,

View File

@@ -25,6 +25,11 @@ namespace ScadaLink.Commons.Types;
/// <param name="TerminalAtUtc">UTC timestamp the row reached a terminal status; null while still active.</param>
/// <param name="SourceInstanceId">Instance id that issued the cached call, when known.</param>
/// <param name="SourceScript">Script that issued the cached call, when known.</param>
/// <param name="SourceNode">
/// Cluster node that submitted the cached call (e.g. <c>"node-a"</c> /
/// <c>"node-b"</c>), captured at enqueue time. Null on rows persisted before
/// the SourceNode stamping migration; stamping itself is wired in a later task.
/// </param>
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);

View File

@@ -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),

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 {
}
}
/// <summary>Field number for the "source_node" field.</summary>
public const int SourceNodeFieldNumber = 22;
private string sourceNode_ = "";
/// <summary>
/// empty string represents null
/// </summary>
[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 {
}
}
/// <summary>Field number for the "source_node" field.</summary>
public const int SourceNodeFieldNumber = 12;
private string sourceNode_ = "";
/// <summary>
/// empty string represents null
/// </summary>
[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;
}
}
}
}

View File

@@ -70,6 +70,15 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
.HasMaxLength(256)
.IsUnicode(false);
// SourceNode (Audit Log #23, SourceNode-stamping): node-local identifier of the
// cluster member that produced the row (e.g. "node-a", "central-a"). NULL is
// valid for reconciled rows from a retired node and for direct-write rows
// produced before this feature shipped. ASCII — varchar(64), no unicode.
builder.Property(e => 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<AuditEve
.HasFilter("[ParentExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_ParentExecution");
// SourceNode composite index (Audit Log #23, SourceNode-stamping): backs
// per-node Central UI / health-dashboard queries (e.g. "rows produced by
// central-a, newest first"). Created via raw SQL in the migration so it lands
// on the ps_AuditLog_Month(OccurredAtUtc) partition scheme like every other
// IX_AuditLog_* index — keeps the partition-switch purge path intact.
builder.HasIndex(e => 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");

View File

@@ -47,6 +47,16 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
builder.Property(n => 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.

View File

@@ -59,6 +59,16 @@ public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall
builder.Property(s => 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).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the <c>SourceNode</c> column to the centralized <c>AuditLog</c> table (#23,
/// SourceNode-stamping). <c>SourceNode</c> identifies the cluster node that produced the
/// audit row (e.g. <c>node-a</c>, <c>central-a</c>) — ASCII-only, so <c>varchar(64)</c>
/// not <c>nvarchar</c>. <c>NULL</c> is valid (reconciled rows from a retired node,
/// central direct-write rows pre-this-feature).
///
/// The change is purely additive:
/// 1. <c>SourceNode varchar(64) NULL</c> is added with no default, so the operation
/// is a metadata-only <c>ALTER TABLE … ADD</c> — it does NOT rewrite the
/// monthly-partitioned <c>AuditLog</c> table, and historical rows stay <c>NULL</c>.
/// 2. <c>IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)</c> is created via raw
/// SQL so it lands on the <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme,
/// matching every other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned
/// preserves the partition-switch purge path (see
/// AuditLogRepository.SwitchOutPartitionAsync).
/// </summary>
public partial class AddAuditLogSourceNode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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);");
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the <c>SourceNode</c> column to the central <c>Notifications</c> table (#21,
/// SourceNode-stamping). <c>SourceNode</c> identifies the cluster node that produced the
/// notification (e.g. <c>node-a</c>, <c>central-a</c>) — ASCII-only, so <c>varchar(64)</c>
/// not <c>nvarchar</c>. <c>NULL</c> is valid for rows that pre-date this feature.
///
/// The change is purely additive: <c>SourceNode varchar(64) NULL</c> is added with no
/// default, so the operation is a metadata-only <c>ALTER TABLE … ADD</c>. Unlike
/// <c>AuditLog</c>, the <c>Notifications</c> table is NOT partitioned, so a plain
/// <c>ADD</c> is fine. No index — Notification Outbox KPIs are per-site, not per-node,
/// on this table; <c>SourceNode</c> is only echoed onto <c>NotifyDeliver</c> audit rows
/// (#23) for cross-row correlation. Historical rows stay <c>NULL</c>.
/// </summary>
public partial class AddNotificationSourceNode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SourceNode",
table: "Notifications",
type: "varchar(64)",
unicode: false,
maxLength: 64,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SourceNode",
table: "Notifications");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the <c>SourceNode</c> column to the central <c>SiteCalls</c> table (#22,
/// SourceNode-stamping). <c>SourceNode</c> identifies the cluster node that produced the
/// row (e.g. <c>node-a</c>, <c>central-a</c>) — ASCII-only, so <c>varchar(64)</c> not
/// <c>nvarchar</c>. <c>NULL</c> is valid for rows that pre-date this feature and for
/// reconciled rows from a retired node.
///
/// The change is purely additive: <c>SourceNode varchar(64) NULL</c> is added with no
/// default, so the operation is a metadata-only <c>ALTER TABLE … ADD</c>. The
/// <c>SiteCalls</c> table is NOT partitioned (operational state, not audit), so a plain
/// <c>ADD</c> is fine. No index — Site Call Audit KPIs are per-site, not per-node, on
/// this table; <c>SourceNode</c> is operational metadata, never a query predicate here.
/// Historical rows stay <c>NULL</c>.
/// </summary>
public partial class AddSiteCallSourceNode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SourceNode",
table: "SiteCalls",
type: "varchar(64)",
unicode: false,
maxLength: 64,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SourceNode",
table: "SiteCalls");
}
}
}

View File

@@ -113,6 +113,11 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsUnicode(false)
.HasColumnType("varchar(128)");
b.Property<string>("SourceNode")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("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<int>("RetryCount")
.HasColumnType("int");
b.Property<string>("SourceNode")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("SourceSite")
.IsRequired()
.HasMaxLength(64)
@@ -813,6 +826,11 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("SourceNode")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("SourceScript")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");

View File

@@ -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
}
}
/// <summary>
/// Distinct non-null <c>SourceNode</c> values for the Audit Log page's
/// Node filter dropdown. EF Core translates this to
/// <c>SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL ORDER BY SourceNode</c>
/// — a single index-less scan, but the column is bounded (one entry per
/// node in the cluster, currently &lt;10) and the Central UI caches the
/// result for ~60s, so a periodic scan is acceptable.
/// </summary>
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
return await _context.Set<AuditEvent>()
.AsNoTracking()
.Where(e => e.SourceNode != null)
.Select(e => e.SourceNode!)
.Distinct()
.OrderBy(n => n)
.ToListAsync(ct);
}
/// <summary>
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
/// list. A null/empty aggregate (a stub node with no rows) yields an empty

View File

@@ -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);

View File

@@ -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})

View File

@@ -0,0 +1,20 @@
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.Host;
/// <summary>
/// Binds <see cref="INodeIdentityProvider"/> to <see cref="NodeOptions.NodeName"/>.
/// Empty or whitespace values are normalised to <c>null</c>; otherwise the value
/// is returned trimmed.
/// </summary>
internal sealed class NodeIdentityProvider : INodeIdentityProvider
{
public string? NodeName { get; }
public NodeIdentityProvider(IOptions<NodeOptions> nodeOptions)
{
var configured = nodeOptions.Value.NodeName;
NodeName = string.IsNullOrWhiteSpace(configured) ? null : configured.Trim();
}
}

View File

@@ -4,6 +4,14 @@ public class NodeOptions
{
public string Role { get; set; } = string.Empty;
public string NodeHostname { get; set; } = string.Empty;
/// <summary>
/// Operator-configured semantic node name used to stamp the SourceNode
/// column on audit rows. Conventional values are <c>node-a</c>/<c>node-b</c>
/// on site nodes and <c>central-a</c>/<c>central-b</c> on central nodes,
/// but the value is a free-form label — no validation is enforced.
/// </summary>
public string NodeName { get; set; } = string.Empty;
public string? SiteId { get; set; }
public int RemotingPort { get; set; } = 8081;
public int GrpcPort { get; set; } = 8083;

View File

@@ -7,6 +7,10 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ScadaLink.Host.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka.Cluster.Hosting" />
<PackageReference Include="Akka.Cluster.Tools" />

View File

@@ -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<HealthMonitoringOptions>(config.GetSection("ScadaLink:HealthMonitoring"));
services.Configure<NotificationOptions>(config.GetSection("ScadaLink:Notification"));
services.Configure<LoggingOptions>(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<INodeIdentityProvider, NodeIdentityProvider>();
}
}

View File

@@ -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<INotificationOutboxRepository>();
@@ -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,

View File

@@ -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);
}
/// <summary>

View File

@@ -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<IAuditWriter>();
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.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
{

View File

@@ -71,6 +71,19 @@ public class ScriptRuntimeContext
/// </summary>
private readonly string _siteId;
/// <summary>
/// SourceNode-stamping (Task 13/14): the cluster node name supplied by
/// <c>INodeIdentityProvider</c> on the local host — <c>node-a</c>/<c>node-b</c>
/// for site nodes. Stamped onto <c>NotificationSubmit.SourceNode</c> by
/// <see cref="NotifyTarget.Send"/> and onto <c>SiteCallOperational.SourceNode</c>
/// by the four <see cref="ExternalSystemHelper"/> / <see cref="DatabaseHelper"/>
/// cached-call telemetry construction sites so central can persist it on the
/// <c>Notifications</c> / <c>SiteCalls</c> 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.
/// </summary>
private readonly string? _sourceNode;
/// <summary>
/// Notification Outbox (FU3): identifier of the script currently executing in this
/// context — stamped onto <c>NotificationSubmit.SourceScript</c> 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);
/// <summary>
/// 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);
/// <summary>
/// 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);
/// <summary>
/// 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;
/// <summary>
/// SourceNode-stamping (Task 14): the local cluster node name on
/// which this script is executing (<c>node-a</c>/<c>node-b</c>).
/// Stamped onto <c>SiteCallOperational.SourceNode</c> on the three
/// cached-call telemetry construction sites (CachedSubmit + the two
/// immediate-completion rows) so central can persist it on the
/// <c>SiteCalls</c> row.
/// </summary>
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<ExternalCallResult> 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
/// </summary>
private readonly IAuditWriter? _auditWriter;
/// <summary>
/// SourceNode-stamping (Task 14): the local cluster node name on
/// which this script is executing (<c>node-a</c>/<c>node-b</c>).
/// Stamped onto <c>SiteCallOperational.SourceNode</c> at the
/// <c>Database.CachedWrite</c> CachedSubmit telemetry construction
/// site so central can persist it on the <c>SiteCalls</c> row.
/// </summary>
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<System.Data.Common.DbConnection> 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
/// </summary>
private readonly IAuditWriter? _auditWriter;
/// <summary>
/// SourceNode-stamping (Task 13): the cluster node name on which this
/// script is executing — <c>node-a</c>/<c>node-b</c>. Stamped onto
/// <c>NotificationSubmit.SourceNode</c> by <see cref="NotifyTarget.Send"/>
/// so central can persist it on the <c>Notifications</c> row.
/// </summary>
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;
}
/// <summary>
@@ -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);
}
/// <summary>
@@ -1455,6 +1541,15 @@ public class ScriptRuntimeContext
/// </summary>
private readonly IAuditWriter? _auditWriter;
/// <summary>
/// SourceNode-stamping (Task 13): the cluster node name on which this
/// script is executing (<c>node-a</c>/<c>node-b</c>). Stamped onto the
/// <c>NotificationSubmit.SourceNode</c> field in <see cref="Send"/> so
/// the central <c>NotificationOutboxActor</c> can persist it on the
/// <c>Notifications</c> row.
/// </summary>
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;
}
/// <summary>
@@ -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);

View File

@@ -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");
}
/// <summary>
/// Additively adds a column to <c>OperationTracking</c> only when it is not
/// already present. SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>, so the
/// schema is probed via <c>PRAGMA table_info</c> first. Idempotent — safe
/// to run on every <see cref="InitializeSchema"/>. Mirrors the
/// <c>SqliteAuditWriter.AddColumnIfMissing</c> precedent.
/// </summary>
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();
}
/// <inheritdoc/>
@@ -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
{

View File

@@ -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<ILoggerFactory, NullLoggerFactory>();
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<INodeIdentityProvider>(new FakeNodeIdentityProvider());
services.AddAuditLog(config);
return services.BuildServiceProvider();
}

View File

@@ -228,5 +228,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
_inner.GetExecutionTreeAsync(executionId, ct);
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
_inner.GetDistinctSourceNodesAsync(ct);
}
}

View File

@@ -86,6 +86,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)

View File

@@ -55,6 +55,9 @@ public class CentralAuditWriteFailuresTests : TestKit
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <summary>

View File

@@ -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<ArgumentNullException>(
() => new CentralAuditWriter(services, null!));
}
// ----- SourceNode stamping (Task 12) ----- //
private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
INodeIdentityProvider? nodeIdentity)
{
var repo = Substitute.For<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped(_ => repo);
var provider = services.BuildServiceProvider();
var writer = new CentralAuditWriter(
provider,
NullLogger<CentralAuditWriter>.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<AuditEvent>(e => e.SourceNode == "central-a"),
Arg.Any<CancellationToken>());
}
[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<AuditEvent>(e => e.SourceNode == "central-b"),
Arg.Any<CancellationToken>());
}
[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<AuditEvent>(e => e.SourceNode == null),
Arg.Any<CancellationToken>());
}
[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<AuditEvent>(e => e.SourceNode == "node-z"),
Arg.Any<CancellationToken>());
}
[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<AuditEvent>(e => e.SourceNode == null),
Arg.Any<CancellationToken>());
}
}

View File

@@ -101,6 +101,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <summary>

View File

@@ -72,6 +72,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
Channel: "ApiOutbound",
Target: target,
SourceSite: siteId,
SourceNode: null,
Status: "Submitted",
RetryCount: 0,
LastError: null,

View File

@@ -61,6 +61,7 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
Channel: "DbOutbound",
Target: target,
SourceSite: siteId,
SourceNode: null,
Status: "Submitted",
RetryCount: 0,
LastError: null,

View File

@@ -9,6 +9,7 @@ using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
@@ -114,6 +115,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:auditlog-e1-{Guid.NewGuid():N}?mode=memory&cache=shared");

View File

@@ -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<MsSqlMigration
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared");

View File

@@ -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,

View File

@@ -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<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:cachedcall-g-{Guid.NewGuid():N}?mode=memory&cache=shared");

View File

@@ -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<MsSqlMigrationFi
ChannelCapacity = 4096,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:outage-{Guid.NewGuid():N}?mode=memory&cache=shared");

View File

@@ -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<MsSqlMig
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:auditlog-parentexec-{Guid.NewGuid():N}?mode=memory&cache=shared");
var ring = new RingBufferFallback();

View File

@@ -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<MsSqlMigrati
});
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
// 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<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source=file:auditlog-h-{Guid.NewGuid():N}?mode=memory&cache=shared");
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>

View File

@@ -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<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
await using var _disposeSqlite = sqliteWriter;

View File

@@ -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<SqliteAuditWriter>.Instance);
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider());
}
private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => new()

View File

@@ -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<SqliteAuditWriter>.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<SqliteAuditWriter>.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) ----- //
/// <summary>
/// The pre-SourceNode <c>AuditLog</c> schema — the 22-column CREATE TABLE
/// that HAS <c>ExecutionId</c> + <c>ParentExecutionId</c> but is WITHOUT
/// <c>SourceNode</c>. A deployment that ran the ParentExecutionId branch
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
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);
""";
/// <summary>
/// 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.
/// </summary>
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);
}
}
}

View File

@@ -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<SqliteAuditWriter>.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);
}
}

View File

@@ -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<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns("node-a");
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = new CachedCallLifecycleBridge(
_forwarder, NullLogger<CachedCallLifecycleBridge>.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<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.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<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns((string?)null);
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = new CachedCallLifecycleBridge(
_forwarder, NullLogger<CachedCallLifecycleBridge>.Instance, nodeIdentity);
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
var packet = Assert.Single(captured);
Assert.Null(packet.Operational.SourceNode);
}
}

View File

@@ -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<CancellationToken>());
// 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<CancellationToken>());
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<CancellationToken>());
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<CancellationToken>());
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<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>());
}
@@ -222,6 +232,7 @@ public class CachedCallTelemetryForwarderTests
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>())
.Throws(new InvalidOperationException("sqlite locked"));
@@ -242,4 +253,55 @@ public class CachedCallTelemetryForwarderTests
await Assert.ThrowsAsync<ArgumentNullException>(
() => 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<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns("node-a");
var sut = new CachedCallTelemetryForwarder(
_writer, _tracking, NullLogger<CachedCallTelemetryForwarder>.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<CancellationToken>());
}
[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<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns((string?)null);
var sut = new CachedCallTelemetryForwarder(
_writer, _tracking, NullLogger<CachedCallTelemetryForwarder>.Instance, nodeIdentity);
await sut.ForwardAsync(SubmitPacket(), CancellationToken.None);
await _tracking.Received(1).RecordEnqueueAsync(
_id,
"ApiOutbound",
"ERP.GetOrder",
"inst-1",
"ScriptActor:doStuff",
null,
Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,20 @@
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.AuditLog.Tests.TestSupport;
/// <summary>
/// Test fake for <see cref="INodeIdentityProvider"/>. Returns the configured
/// <see cref="NodeName"/> verbatim — including <c>null</c> — so tests can
/// exercise both the "stamped" and "unconfigured" branches of the SourceNode
/// stamping logic in <see cref="ScadaLink.AuditLog.Site.SqliteAuditWriter"/>
/// and <see cref="ScadaLink.AuditLog.Central.CentralAuditWriter"/>.
/// </summary>
internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider
{
public string? NodeName { get; }
public FakeNodeIdentityProvider(string? nodeName = null)
{
NodeName = nodeName;
}
}

View File

@@ -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<IAuditLogQueryService>();
_auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a", "central-b" }));
_auditLogQueryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
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<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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()
{

View File

@@ -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<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(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()
{

View File

@@ -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<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(
SourceNodes: new[] { "central-a", "site-plant-a-node-a" });
repo.QueryAsync(filter, Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
await sut.QueryAsync(filter);
await repo.Received(1).QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.SourceNodes != null
&& f.SourceNodes.Count == 2
&& f.SourceNodes.Contains("central-a")
&& f.SourceNodes.Contains("site-plant-a-node-a")),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall()
{
var repo = Substitute.For<IAuditLogRepository>();
var expected = new[] { "central-a", "central-b", "site-plant-a-node-a" };
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(expected));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.GetDistinctSourceNodesAsync();
Assert.Equal(expected, result);
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
}
[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<IAuditLogRepository>();
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(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<CancellationToken>());
}
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
{
SiteAuditBacklogSnapshot? backlog = pending.HasValue

View File

@@ -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()
{

View File

@@ -0,0 +1,40 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Entities.Audit;
/// <summary>
/// Verifies the <see cref="SiteCall"/> central operational entity carries the
/// SourceNode column (additive, nullable) through init-only construction and
/// <c>with</c> expressions. Sibling to <see cref="AuditEventTests"/>.
/// </summary>
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);
}
}

View File

@@ -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()
{

View File

@@ -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,

View File

@@ -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<NotificationSubmit>(json);
Assert.NotNull(roundTripped);
Assert.Equal("node-a", roundTripped!.SourceNode);
}
[Fact]
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
{

View File

@@ -0,0 +1,42 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Types;
/// <summary>
/// Verifies <see cref="SiteCallOperational"/> — the positional record carried on
/// the combined <c>CachedCallTelemetry</c> packet — round-trips the SourceNode
/// field through positional construction (where the parameter sits between
/// <c>SourceSite</c> and <c>Status</c>, mirroring the central <c>SiteCalls</c>
/// table column order).
/// </summary>
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);
}
}

Some files were not shown because too many files have changed in this diff Show More