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:
24
CLAUDE.md
24
CLAUDE.md
@@ -1,17 +1,21 @@
|
||||
# ScadaLink Design Documentation Project
|
||||
# ScadaLink Implementation Project
|
||||
|
||||
This project contains design documentation for a distributed SCADA system built on Akka.NET. The documents describe a hub-and-spoke architecture with a central cluster and multiple site clusters.
|
||||
This is the full **implementation** project for ScadaLink — a distributed SCADA system built on Akka.NET in a hub-and-spoke architecture (one central cluster + multiple site clusters). It contains source code, tests, deployable docker topology, and the design documentation that the code implements. The design docs are the spec; `src/` is the binary.
|
||||
|
||||
When a change is requested, the default assumption is: update the design doc *and* the code *and* the tests *and* (if it ships) the docker deploy — together, in one session, with `git diff` review before committing.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/` — C#/.NET implementation, one project per component (e.g. `ScadaLink.AuditLog`, `ScadaLink.NotificationOutbox`, `ScadaLink.SiteCallAudit`, `ScadaLink.CentralUI`, `ScadaLink.Host`, …). Solution file: `ScadaLink.slnx`.
|
||||
- `tests/` — Test projects (unit + integration).
|
||||
- `docker/` — 8-node cluster topology (2 central + 3 sites), `deploy.sh`, per-node `appsettings.*.json`. See [`docker/README.md`](docker/README.md) for setup, ports, and management commands. Rebuild + redeploy with `bash docker/deploy.sh`.
|
||||
- `infra/` — Docker Compose for local test services (LDAP, MS SQL, OPC UA, SMTP, REST API, Traefik).
|
||||
- `README.md` — Master index with component table and architecture diagrams.
|
||||
- `docs/requirements/HighLevelReqs.md` — Complete high-level requirements covering all functional areas.
|
||||
- `docs/requirements/Component-*.md` — Individual component design documents (one per component).
|
||||
- `docs/requirements/Component-*.md` — Individual component design documents (one per component) — the spec the code implements.
|
||||
- `docs/test_infra/test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL, SMTP, REST API, Traefik).
|
||||
- `docs/plans/` — Design decision documents from refinement sessions.
|
||||
- `docs/plans/` — Design decision and implementation-plan documents from refinement sessions.
|
||||
- `AkkaDotNet/` — Akka.NET reference documentation and best practices notes.
|
||||
- `infra/` — Docker Compose and config files for local test services.
|
||||
- `docker/` — Docker infrastructure for the 8-node cluster topology (2 central + 3 sites). See [`docker/README.md`](docker/README.md) for cluster setup, port allocation, and management commands.
|
||||
|
||||
## Document Conventions
|
||||
|
||||
@@ -31,10 +35,11 @@ This project contains design documentation for a distributed SCADA system built
|
||||
|
||||
## Editing Rules
|
||||
|
||||
- Edit documents in place. Do not create copies or backup files.
|
||||
- When a change affects multiple documents, update all affected documents in the same session.
|
||||
- Edit documents and code in place. Do not create copies or backup files.
|
||||
- When a change affects multiple documents or projects, update them all in the same session — design doc, entities/repos, actors/services, UI, tests, migrations, and deploy config travel together.
|
||||
- Use `git diff` to review changes before committing.
|
||||
- Commit related changes together with a descriptive message summarizing the design decision.
|
||||
- Commit related changes together with a descriptive message summarizing the design decision and the implementation slice.
|
||||
- After non-trivial code changes, build (`dotnet build ScadaLink.slnx`) and run relevant tests before declaring done; for cluster-runtime changes, rebuild the image with `bash docker/deploy.sh`.
|
||||
|
||||
## Current Component List (23 components)
|
||||
|
||||
@@ -140,6 +145,7 @@ This project contains design documentation for a distributed SCADA system built
|
||||
- Audit-write failure NEVER aborts the user-facing action — audit is best-effort, the action's own success/failure path is authoritative.
|
||||
- 365-day central retention with monthly partition-switch purge; 7-day site SQLite retention with a hard `ForwardState` invariant (no row purged until forwarded or reconciled).
|
||||
- Append-only enforced via DB roles (writer role has INSERT only, no UPDATE/DELETE); hash-chain tamper evidence and Parquet archival are deferred to v1.x.
|
||||
- Node-of-origin is captured alongside site-of-origin: `SourceNode` (`varchar(64)` NULL) on `AuditLog`, `Notifications`, and `SiteCalls` — `node-a`/`node-b` for site rows (qualified by `SourceSiteId`/`SourceSite`), `central-a`/`central-b` for central direct-write rows. Stamped at the writing node, carried verbatim through telemetry + reconciliation, and indexed via `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` on `AuditLog`.
|
||||
- Central UI: new top-level **Audit** nav group + Audit Log page, with drill-ins from Notifications, Site Calls, External Systems, Inbound API Keys, Sites, and Instances.
|
||||
|
||||
### Security & Auth
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ScadaLink": {
|
||||
"Node": {
|
||||
"Role": "Central",
|
||||
"NodeName": "central-a",
|
||||
"NodeHostname": "scadalink-central-a",
|
||||
"RemotingPort": 8081
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ScadaLink": {
|
||||
"Node": {
|
||||
"Role": "Central",
|
||||
"NodeName": "central-b",
|
||||
"NodeHostname": "scadalink-central-b",
|
||||
"RemotingPort": 8081
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ScadaLink": {
|
||||
"Node": {
|
||||
"Role": "Site",
|
||||
"NodeName": "node-a",
|
||||
"NodeHostname": "scadalink-site-a-a",
|
||||
"SiteId": "site-a",
|
||||
"RemotingPort": 8082,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ScadaLink": {
|
||||
"Node": {
|
||||
"Role": "Site",
|
||||
"NodeName": "node-b",
|
||||
"NodeHostname": "scadalink-site-a-b",
|
||||
"SiteId": "site-a",
|
||||
"RemotingPort": 8082,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ScadaLink": {
|
||||
"Node": {
|
||||
"Role": "Site",
|
||||
"NodeName": "node-a",
|
||||
"NodeHostname": "scadalink-site-b-a",
|
||||
"SiteId": "site-b",
|
||||
"RemotingPort": 8082,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ScadaLink": {
|
||||
"Node": {
|
||||
"Role": "Site",
|
||||
"NodeName": "node-b",
|
||||
"NodeHostname": "scadalink-site-b-b",
|
||||
"SiteId": "site-b",
|
||||
"RemotingPort": 8082,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ScadaLink": {
|
||||
"Node": {
|
||||
"Role": "Site",
|
||||
"NodeName": "node-a",
|
||||
"NodeHostname": "scadalink-site-c-a",
|
||||
"SiteId": "site-c",
|
||||
"RemotingPort": 8082,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ScadaLink": {
|
||||
"Node": {
|
||||
"Role": "Site",
|
||||
"NodeName": "node-b",
|
||||
"NodeHostname": "scadalink-site-c-b",
|
||||
"SiteId": "site-c",
|
||||
"RemotingPort": 8082,
|
||||
|
||||
940
docs/plans/2026-05-23-audit-source-node.md
Normal file
940
docs/plans/2026-05-23-audit-source-node.md
Normal 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 1–4:** Mirror Task 6 for the `Notifications` table. No new index (the spec says index only on `AuditLog`).
|
||||
|
||||
```bash
|
||||
git commit -m "feat(db): add SourceNode column to Notifications"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: EF migration — add `SourceNode` to `SiteCalls`
|
||||
|
||||
**Files:** equivalents under `SiteCalls`. No new index.
|
||||
|
||||
**Step 1–4:** Mirror Task 6. Configuration file is `SiteCallEntityTypeConfiguration.cs`.
|
||||
|
||||
```bash
|
||||
git commit -m "feat(db): add SourceNode column to SiteCalls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Site SQLite `AuditLog` — add `SourceNode` column (idempotent upgrade)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs`
|
||||
- Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs`
|
||||
- Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs`
|
||||
|
||||
**Step 1: Failing test — schema includes SourceNode AND old DBs are upgraded**
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Initialize_creates_AuditLog_with_SourceNode_column()
|
||||
{
|
||||
using var writer = new SqliteAuditWriter(/*…in-memory…*/);
|
||||
var cols = await ReadColumnsAsync("AuditLog");
|
||||
Assert.Contains("SourceNode", cols);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
|
||||
{
|
||||
// 1. open a SQLite file and create the OLD schema (no SourceNode)
|
||||
// 2. open SqliteAuditWriter against the same file
|
||||
// 3. assert SourceNode column now exists via PRAGMA table_info
|
||||
}
|
||||
```
|
||||
|
||||
Run: expected FAIL.
|
||||
|
||||
**Step 2: Update `InitializeSchema`**
|
||||
|
||||
Add `SourceNode TEXT NULL` to the `CREATE TABLE IF NOT EXISTS AuditLog (...)` DDL. Add a second `PRAGMA table_info`-based upgrade block matching the existing `ExecutionId` / `ParentExecutionId` pattern:
|
||||
|
||||
```csharp
|
||||
if (!columns.Contains("SourceNode", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE AuditLog ADD COLUMN SourceNode TEXT NULL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Update INSERT statement to include SourceNode**
|
||||
|
||||
In the parameterized batch insert SQL (lines ~270-284), add the column + parameter.
|
||||
|
||||
**Step 4: Run + commit**
|
||||
|
||||
```bash
|
||||
dotnet test tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs --filter SourceNode -v n
|
||||
dotnet build ScadaLink.slnx
|
||||
git add src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs \
|
||||
tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs \
|
||||
tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs
|
||||
git commit -m "feat(audit): add SourceNode column to site SQLite AuditLog (idempotent upgrade)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Site SQLite `OperationTracking` — add `SourceNode` column
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs`
|
||||
- Modify: tests under `tests/ScadaLink.SiteRuntime.Tests/Tracking/` (or closest)
|
||||
|
||||
**Step 1: Failing test** — same pattern as Task 9, asserting the `OperationTracking` table grows a `SourceNode TEXT NULL` column on both fresh and pre-existing DBs.
|
||||
|
||||
**Step 2: Add column to CREATE TABLE + idempotent PRAGMA-based ALTER**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS OperationTracking (
|
||||
-- ...existing columns...
|
||||
SourceNode TEXT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Plus the same PRAGMA upgrade block.
|
||||
|
||||
**Step 3: Update `RecordEnqueueAsync` signature**
|
||||
|
||||
Accept `string? sourceNode` and pass through to INSERT.
|
||||
|
||||
**Step 4: Run + commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(site-runtime): add SourceNode column to OperationTracking + thread through RecordEnqueueAsync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Stamp `SourceNode` at the site `SqliteAuditWriter`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs`
|
||||
- Modify: DI registration that wires `SqliteAuditWriter` (likely `src/ScadaLink.AuditLog/AuditLogServiceCollectionExtensions.cs` or equivalent)
|
||||
- Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs`
|
||||
|
||||
**Step 1: Failing test**
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task WriteAsync_stamps_SourceNode_from_INodeIdentityProvider_when_event_has_none()
|
||||
{
|
||||
var nodeId = Substitute.For<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.
|
||||
27
docs/plans/2026-05-23-audit-source-node.md.tasks.json
Normal file
27
docs/plans/2026-05-23-audit-source-node.md.tasks.json
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -118,6 +118,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
{
|
||||
("OccurredAtUtc", "OccurredAtUtc"),
|
||||
("Site", "Site"),
|
||||
("Node", "Node"),
|
||||
("Channel", "Channel"),
|
||||
("Kind", "Kind"),
|
||||
("Status", "Status"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ public interface IOperationTrackingStore
|
||||
string? targetSummary,
|
||||
string? sourceInstanceId,
|
||||
string? sourceScript,
|
||||
string? sourceNode,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>>=</c> / <c><=</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>>=</c> / <c><=</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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
1647
src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs
generated
Normal file
1647
src/ScadaLink.ConfigurationDatabase/Migrations/20260523201754_AddAuditLogSourceNode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1652
src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs
generated
Normal file
1652
src/ScadaLink.ConfigurationDatabase/Migrations/20260523201950_AddNotificationSourceNode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1657
src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs
generated
Normal file
1657
src/ScadaLink.ConfigurationDatabase/Migrations/20260523202131_AddSiteCallSourceNode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})
|
||||
|
||||
20
src/ScadaLink.Host/NodeIdentityProvider.cs
Normal file
20
src/ScadaLink.Host/NodeIdentityProvider.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -72,6 +72,7 @@ public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMigr
|
||||
Channel: "ApiOutbound",
|
||||
Target: target,
|
||||
SourceSite: siteId,
|
||||
SourceNode: null,
|
||||
Status: "Submitted",
|
||||
RetryCount: 0,
|
||||
LastError: null,
|
||||
|
||||
@@ -61,6 +61,7 @@ public class CachedWriteCombinedTelemetryTests : TestKit, IClassFixture<MsSqlMig
|
||||
Channel: "DbOutbound",
|
||||
Target: target,
|
||||
SourceSite: siteId,
|
||||
SourceNode: null,
|
||||
Status: "Submitted",
|
||||
RetryCount: 0,
|
||||
LastError: null,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user