Adds the draft-editor tab + page surface for authoring Phase 7 virtual tags and scripted alarms, plus the /alarms/historian operator diagnostics page. Monaco loads from CDN via a progressive-enhancement JS shim — the textarea works immediately so the page is functional even if the CDN is unreachable. ## New services (Admin) - ScriptService — CRUD for Script entity. SHA-256 SourceHash recomputed on save so Core.Scripting's CompiledScriptCache hits on re-publish of unchanged source + misses when the source actually changes. - VirtualTagService — CRUD for VirtualTag, with Enabled toggle. - ScriptedAlarmService — CRUD for ScriptedAlarm + lookup of persistent ScriptedAlarmState (logical-id-keyed per plan decision #14). - ScriptTestHarnessService — pre-publish dry-run. Enforces plan decision #22: only inputs the DependencyExtractor identifies can be supplied. Missing / extra synthetic inputs surface as dedicated outcomes. Captures SetVirtualTag writes + Serilog events from the script so the operator can see both the output + the log output before publishing. - HistorianDiagnosticsService — surfaces the local-process IAlarmHistorianSink state on /alarms/historian. Null sink reports Disabled + swallows retry. Live SqliteStoreAndForwardSink reports real queue depth + last-error + drain state and routes the Retry-dead-lettered button through. ## New UI - ScriptsTab.razor (inside DraftEditor tabs) — list + create/edit/delete scripts with Monaco editor + dependency preview + test-harness run panel showing output + writes + log emissions. - ScriptEditor.razor — reusable Monaco-backed textarea. Loads editor from CDN via wwwroot/js/monaco-loader.js. Textarea stays authoritative for Blazor binding; Monaco mirrors into it on every keystroke. - AlarmsHistorian.razor (/alarms/historian) — queue depth + dead-letter depth + drain state badge + last-error banner + Retry-dead-lettered button. - DraftEditor.razor — new "Scripts" tab. ## DI wiring All five services registered in Program.cs. Null historian sink bound at Admin composition time (real SqliteStoreAndForwardSink lives in the Server process). ## Tests — 13/13 Phase7ServicesTests covers: - ScriptService: Add generates logical id + hash, Update recomputes hash on source change, Update same-source keeps hash (cache-hit preservation), Delete is idempotent - VirtualTagService: round-trips trigger flags, Enabled toggle works - ScriptedAlarmService: HistorizeToAveva defaults true per plan decision #15 - ScriptTestHarness: successful run captures output + writes, rejects missing / extra inputs, rejects non-literal paths, compile errors surface as Threw - HistorianDiagnosticsService: null sink reports Disabled + retry returns 0
130 lines
5.4 KiB
C#
130 lines
5.4 KiB
C#
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;
|
|
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
|
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Host.UseSerilog((ctx, cfg) => cfg
|
|
.MinimumLevel.Information()
|
|
.WriteTo.Console()
|
|
.WriteTo.File("logs/otopcua-admin-.log", rollingInterval: RollingInterval.Day));
|
|
|
|
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddSignalR();
|
|
|
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.AddCookie(o =>
|
|
{
|
|
o.Cookie.Name = "OtOpcUa.Admin";
|
|
o.LoginPath = "/login";
|
|
o.ExpireTimeSpan = TimeSpan.FromHours(8);
|
|
});
|
|
|
|
builder.Services.AddAuthorizationBuilder()
|
|
.AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin))
|
|
.AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin));
|
|
|
|
builder.Services.AddCascadingAuthenticationState();
|
|
|
|
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
|
opt.UseSqlServer(builder.Configuration.GetConnectionString("ConfigDb")
|
|
?? throw new InvalidOperationException("ConnectionStrings:ConfigDb not configured")));
|
|
|
|
builder.Services.AddScoped<ClusterService>();
|
|
builder.Services.AddScoped<GenerationService>();
|
|
builder.Services.AddScoped<EquipmentService>();
|
|
builder.Services.AddScoped<UnsService>();
|
|
builder.Services.AddScoped<NamespaceService>();
|
|
builder.Services.AddScoped<DriverInstanceService>();
|
|
builder.Services.AddScoped<NodeAclService>();
|
|
builder.Services.AddScoped<PermissionProbeService>();
|
|
builder.Services.AddScoped<AclChangeNotifier>();
|
|
builder.Services.AddScoped<ReservationService>();
|
|
builder.Services.AddScoped<DraftValidationService>();
|
|
builder.Services.AddScoped<AuditLogService>();
|
|
builder.Services.AddScoped<HostStatusService>();
|
|
builder.Services.AddScoped<ClusterNodeService>();
|
|
builder.Services.AddSingleton<RedundancyMetrics>();
|
|
builder.Services.AddScoped<EquipmentImportBatchService>();
|
|
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
|
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
|
|
|
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
|
|
// harness, and historian diagnostics. The historian sink is the Null variant here —
|
|
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
|
|
// from whichever sink is provided at composition time.
|
|
builder.Services.AddScoped<ScriptService>();
|
|
builder.Services.AddScoped<VirtualTagService>();
|
|
builder.Services.AddScoped<ScriptedAlarmService>();
|
|
builder.Services.AddScoped<ScriptTestHarnessService>();
|
|
builder.Services.AddScoped<HistorianDiagnosticsService>();
|
|
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
|
|
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
|
|
|
|
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
|
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
|
// filesystem operations.
|
|
builder.Services.Configure<CertTrustOptions>(builder.Configuration.GetSection(CertTrustOptions.SectionName));
|
|
builder.Services.AddSingleton<CertTrustService>();
|
|
|
|
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
|
builder.Services.Configure<LdapOptions>(
|
|
builder.Configuration.GetSection("Authentication:Ldap"));
|
|
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();
|
|
app.UseStaticFiles();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAntiforgery();
|
|
|
|
app.MapPost("/auth/logout", async (HttpContext ctx) =>
|
|
{
|
|
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
ctx.Response.Redirect("/");
|
|
});
|
|
|
|
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();
|
|
|
|
public partial class Program;
|