Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
ef53553e9d OTel Prometheus exporter wiring — RedundancyMetrics meter now scraped at /metrics. Closes task #201. Picked Prometheus over OTLP per the earlier recommendation (pull-based means no OTel Collector deployment required for the common K8s/containers case; the endpoint ASP.NET-hosts inside the Admin app already, so one less moving part). Adds two NuGet refs to the Admin csproj: OpenTelemetry.Extensions.Hosting 1.15.2 (stable) + OpenTelemetry.Exporter.Prometheus.AspNetCore 1.15.2-beta.1 (the exporter has historically been beta-only; rest of the OTel ecosystem treats it as production-acceptable + it's what the upstream OTel docs themselves recommend for AspNetCore hosts). Program.cs gains a Metrics:Prometheus:Enabled toggle (defaults true; setting to false disables both the MeterProvider registration + the scrape endpoint entirely for locked-down deployments). When enabled, AddOpenTelemetry().WithMetrics() registers a MeterProvider that subscribes to the "ZB.MOM.WW.OtOpcUa.Redundancy" meter (the exact MeterName constant on RedundancyMetrics) + wires AddPrometheusExporter. MapPrometheusScrapingEndpoint() appends a /metrics handler producing the Prometheus text-format output; deliberately NOT authenticated because scrape jobs typically run on a trusted network + operators who need auth wrap the endpoint behind a reverse-proxy basic-auth gate per fleet-ops convention. appsettings.json declares the toggle with Enabled: true so the default deploy gets metrics automatically — turning off is the explicit action. Future meters (resilience tracker + host status + auth probe) just AddMeter("Name") alongside the existing call to start flowing through the same endpoint without more infrastructure. Admin project builds 0 errors; Admin.Tests 92/92 passing (unchanged — the OTel pipeline runs at request time, not test time). Still-pending work that was NOT part of #201's scope: an equivalent setup for the Server project (different MeterNames — the Polly pipeline builder's tracker + host-status publisher) + a metrics cheat-sheet in docs/observability.md documenting each meter's tag set + expected alerting thresholds. Those are natural follow-ups when fleet-ops starts building dashboards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:41:16 -04:00
d1e50db304 Merge pull request (#146) - DiffViewer ACL section 2026-04-20 00:39:11 -04:00
Joseph Doherty
df0d7c2d84 DiffViewer ACL section — extend sp_ComputeGenerationDiff with NodeAcl rows. Closes the final slice of task #196 (draft-diff ACL section). The DiffViewer already rendered a placeholder "NodeAcl" card from the task #156 refactor; it stayed empty because the stored proc didn't emit NodeAcl rows. This PR lights the card up by adding a fifth UNION to the proc. Logical id for NodeAcl is the composite LdapGroup + ScopeKind + ScopeId triple — format "cn=group|Cluster|scope-id" or "cn=group|Cluster|(cluster)" when ScopeId is null (Cluster-wide rows). That shape means a permission-only change (same group + same scope, PermissionFlags shifted) appears as a single Modified row with the full triple as its identifier, whereas a scope move (same group, new ScopeId) correctly surfaces as Added + Removed of two different logical ids. CHECKSUM signature covers ClusterId + PermissionFlags + Notes so both operator-visible changes (permission bitmask) and audit-tier changes (notes) round-trip through the diff. New migration 20260420000001_ExtendComputeGenerationDiffWithNodeAcl.cs ships both Up (install V2 proc) + Down (restore the exact V1 proc text shipped in 20260417215224_StoredProcedures so the migration is reversible). Row-id column widens from nvarchar(64) to nvarchar(128) in V2 since the composite key (group DN + scope + scope-id) exceeds 64 chars comfortably — narrow column would silently truncate in prod. Designer .cs cloned from the prior migration since the EF model is unchanged; DiffViewer.razor section description updated to drop the "(proc-extension pending)" note it carried since task #156 — the card will now populate live. Admin + Core full-solution build clean. No unit-test changes needed — the existing StoredProceduresTests cover the proc-exec path + would immediately catch any SQL syntax regression on next SQL Server integration run. Task #196 fully closed now — Probe-this-permission (slice 1, PR 144), SignalR invalidation (slice 2, PR 145), draft-diff ACL section (this PR).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:37:05 -04:00
16f4b4acad Merge pull request (#145) - ACL + role-grant SignalR invalidation 2026-04-20 00:34:24 -04:00
6 changed files with 1708 additions and 1 deletions

View File

@@ -59,7 +59,7 @@ else
new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"),
new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"),
new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"),
new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (proc-extension pending)"),
new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (logical id = LdapGroup|ScopeKind|ScopeId)"),
};
private List<DiffRow>? _rows;

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using OpenTelemetry.Metrics;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Admin.Components;
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
@@ -70,6 +71,19 @@ builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
builder.Services.AddHostedService<FleetStatusPoller>();
// OpenTelemetry Prometheus exporter — Meter stream from RedundancyMetrics + any future
// Admin-side instrumentation lands on the /metrics endpoint Prometheus scrapes. Pull-based
// means no OTel Collector deployment required for the common deploy-in-a-K8s case; appsettings
// Metrics:Prometheus:Enabled=false disables the endpoint entirely for locked-down deployments.
var metricsEnabled = builder.Configuration.GetValue("Metrics:Prometheus:Enabled", true);
if (metricsEnabled)
{
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m
.AddMeter(RedundancyMetrics.MeterName)
.AddPrometheusExporter());
}
var app = builder.Build();
app.UseSerilogRequestLogging();
@@ -87,6 +101,15 @@ app.MapPost("/auth/logout", async (HttpContext ctx) =>
app.MapHub<FleetStatusHub>("/hubs/fleet");
app.MapHub<AlertHub>("/hubs/alerts");
if (metricsEnabled)
{
// Prometheus scrape endpoint — expose instrumentation registered in the OTel MeterProvider
// above. Emits text-format metrics at /metrics; auth is intentionally NOT required (Prometheus
// scrape jobs typically run on a trusted network). Operators who need auth put the endpoint
// behind a reverse-proxy basic-auth gate per fleet-ops convention.
app.MapPrometheusScrapingEndpoint();
}
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
await app.RunAsync();

View File

@@ -16,6 +16,8 @@
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.2"/>
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.15.2-beta.1"/>
</ItemGroup>
<ItemGroup>

View File

@@ -23,5 +23,10 @@
},
"Serilog": {
"MinimumLevel": "Information"
},
"Metrics": {
"Prometheus": {
"Enabled": true
}
}
}

View File

@@ -0,0 +1,172 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Extends <c>dbo.sp_ComputeGenerationDiff</c> to emit <c>NodeAcl</c> rows alongside the
/// existing Namespace/DriverInstance/Equipment/Tag output — closes the final slice of
/// task #196 (DiffViewer ACL section). Logical id for NodeAcl is a composite
/// <c>LdapGroup|ScopeKind|ScopeId</c> triple so a Change row surfaces whether the grant
/// shifted permissions, moved scope, or was added/removed outright.
/// </summary>
/// <inheritdoc />
public partial class ExtendComputeGenerationDiffWithNodeAcl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV1);
}
private static class Procs
{
/// <summary>V2 — adds the NodeAcl section to the diff output.</summary>
public const string ComputeGenerationDiffV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- NodeAcl section. Logical id is the (LdapGroup, ScopeKind, ScopeId) triple so the diff
-- distinguishes same row with new permissions (Modified via CHECKSUM on PermissionFlags + Notes)
-- from a scope move (which surfaces as Added + Removed of different logical ids).
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
/// <summary>V1 — exact proc shipped in migration 20260417215224_StoredProcedures. Restored on Down().</summary>
public const string ComputeGenerationDiffV1 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
}
}
}