refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,188 @@
using System.Globalization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Audit;
/// <summary>
/// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F).
///
/// <para>
/// CentralUI ships no MVC controllers (see <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Auth.AuthEndpoints"/>
/// and <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.ScriptAnalysisEndpoints"/>),
/// so the brief's "controller" is implemented as a minimal-API endpoint instead.
/// The endpoint streams to <c>Response.Body</c> directly so the export does NOT
/// buffer the full result set in memory — see
/// <see cref="IAuditLogExportService.ExportAsync"/>.
/// </para>
///
/// <para>
/// The route is gated on the <see cref="AuthorizationPolicies.AuditExport"/>
/// policy (#23 M7-T15 / Bundle G) so only roles with the bulk-export
/// permission can pull a CSV — the page-level
/// <see cref="AuthorizationPolicies.OperationalAudit"/> gate is read-only
/// and intentionally narrower. The query-string parser silently drops
/// unrecognised values to match the page-level parser in
/// <c>AuditLogPage.ApplyQueryStringFilters</c> — an unknown enum value yields
/// the same "no constraint" outcome rather than a 400.
/// </para>
/// </summary>
public static class AuditExportEndpoints
{
/// <summary>
/// Default row cap for a single export. Large enough to satisfy realistic
/// operator workflows; mirrors the brief's recommended ceiling. Operators
/// who need more should fall back to the CLI (footnote rendered in the
/// cap-footer line).
/// </summary>
public const int DefaultMaxRows = 100_000;
/// <summary>Registers the audit log CSV export endpoint on the given route builder.</summary>
/// <param name="endpoints">The endpoint route builder to register against.</param>
/// <returns>The same <paramref name="endpoints"/> instance for chaining.</returns>
public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync)
.RequireAuthorization(AuthorizationPolicies.AuditExport);
return endpoints;
}
/// <summary>
/// Handles <c>GET /api/centralui/audit/export</c>. Internal so endpoint
/// tests can call it directly when desirable; the live wire-up goes
/// through the minimal-API map above.
/// </summary>
/// <param name="context">The HTTP context for the current request.</param>
/// <param name="exportService">The export service used to stream audit rows as CSV.</param>
internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService)
{
var filter = ParseFilter(context.Request.Query);
var maxRows = ParseMaxRows(context.Request.Query);
// Stamp the response headers BEFORE the first body write so the client
// sees text/csv + an attachment download right away.
var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
context.Response.ContentType = "text/csv; charset=utf-8";
context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
// Defeat any intermediate buffering proxy so the operator sees rows
// streaming through as the server flushes each repository page.
context.Response.Headers["Cache-Control"] = "no-store";
await exportService.ExportAsync(filter, maxRows, context.Response.Body, context.RequestAborted);
}
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c> dimensions are
/// multi-value: a repeated query param yields a multi-element filter list, a
/// single param a one-element list. Unknown enum names / un-parseable Guids /
/// dates are silently dropped (same lax contract as
/// <c>AuditLogPage.ApplyQueryStringFilters</c>) — an unparseable value within
/// a repeated set is dropped, not the whole set.
/// </summary>
/// <remarks>
/// This endpoint reads the source-site filter from the <c>site</c> query key,
/// whereas the ManagementService export endpoint reads it as
/// <c>sourceSiteId</c>. The divergence is deliberate — each endpoint matches
/// its own CLI / UI URL builder — so do NOT "fix" the two to one key name.
/// </remarks>
/// <param name="query">The query string parameters from the HTTP request.</param>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
var sites = AuditQueryParamParsers.ParseStringList(query["site"]);
string? target = TrimToNullable(query, "target");
string? actor = TrimToNullable(query, "actor");
Guid? correlationId = null;
if (query.TryGetValue("correlationId", out var corrValues)
&& Guid.TryParse(corrValues.ToString(), out var parsedCorr))
{
correlationId = parsedCorr;
}
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
Guid? parentExecutionId = null;
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
{
parentExecutionId = parsedParentExec;
}
DateTime? fromUtc = ParseUtcDate(query, "from");
DateTime? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter(
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sites,
Target: target,
Actor: actor,
CorrelationId: correlationId,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc,
ToUtc: toUtc);
}
/// <summary>
/// Optional <c>maxRows=</c> query-string override. Falls back to
/// <see cref="DefaultMaxRows"/> on a missing / non-positive / unparseable
/// value rather than erroring — same lax contract as the rest of the
/// query parser.
/// </summary>
private static int ParseMaxRows(IQueryCollection query)
{
if (query.TryGetValue("maxRows", out var raw)
&& int.TryParse(raw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
&& parsed > 0)
{
return parsed;
}
return DefaultMaxRows;
}
private static string? TrimToNullable(IQueryCollection query, string key)
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
var v = values.ToString();
return string.IsNullOrWhiteSpace(v) ? null : v.Trim();
}
private static DateTime? ParseUtcDate(IQueryCollection query, string key)
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
if (DateTime.TryParse(
values.ToString(),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
return null;
}
}
@@ -0,0 +1,186 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// <summary>
/// Minimal API endpoints for login/logout. These run outside Blazor Server (standard HTTP POST).
/// On success, signs in via ASP.NET Core cookie authentication and redirects to dashboard.
/// </summary>
public static class AuthEndpoints
{
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/logout</c>, and <c>/auth/ping</c> endpoints on the given route builder.</summary>
/// <param name="endpoints">The route builder to add the endpoints to.</param>
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/auth/login", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
context.Response.Redirect("/login?error=Username+and+password+are+required.");
return;
}
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
{
var errorMsg = Uri.EscapeDataString(authResult.ErrorMessage ?? "Authentication failed.");
context.Response.Redirect($"/login?error={errorMsg}");
return;
}
// Map LDAP groups to roles
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
// Build claims from LDAP auth + role mapping.
// CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped
// — session expiry is owned by the cookie middleware's sliding window
// (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout,
// SlidingExpiration = true). A frozen absolute claim would contradict
// the documented sliding-refresh policy.
var claims = new List<Claim>
{
new(ClaimTypes.Name, authResult.Username ?? username),
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
};
foreach (var role in roleMappingResult.Roles)
{
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
}
if (!roleMappingResult.IsSystemWideDeployment)
{
foreach (var siteId in roleMappingResult.PermittedSiteIds)
{
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
}
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
BuildSignInProperties());
context.Response.Redirect("/");
}).DisableAntiforgery();
endpoints.MapPost("/auth/token", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var authResult = await ldapAuth.AuthenticateAsync(username, password);
if (!authResult.Success)
{
return Results.Json(
new { error = authResult.ErrorMessage ?? "Authentication failed." },
statusCode: 401);
}
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
var token = jwtService.GenerateToken(
authResult.DisplayName ?? username,
authResult.Username ?? username,
roleMappingResult.Roles,
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds);
return Results.Json(new
{
access_token = token,
token_type = "Bearer",
username = authResult.Username ?? username,
display_name = authResult.DisplayName ?? username,
roles = roleMappingResult.Roles,
});
}).DisableAntiforgery();
// Logout is a state-changing authenticated action (CentralUI-017): it
// keeps antiforgery validation enabled so it cannot be triggered
// cross-site. The NavMenu sign-out form includes the antiforgery token
// (rendered by the <AntiforgeryToken /> component). There is deliberately
// no GET /logout route — a state-changing GET is itself a CSRF vector
// (an <img src="/logout"> would forcibly log a user out).
endpoints.MapPost("/auth/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
context.Response.Redirect("/login");
});
// CentralUI-020: liveness probe for the client-side idle-logout check.
// The Blazor circuit's CookieAuthenticationStateProvider serves a frozen
// constructor-time principal (CentralUI-004), so a circuit can never
// observe a server-side cookie expiry by polling the auth state.
// SessionExpiry instead polls this endpoint via fetch(): being a normal
// HTTP request, the cookie middleware re-validates (and slides) the
// cookie on every hit. It deliberately does NOT use RequireAuthorization
// — that would make the middleware answer a lapsed request with a 302 to
// /login, which fetch() follows transparently and reads as a 200 login
// page. Allowing anonymous access and returning 200/401 ourselves gives
// the client an unambiguous expiry signal.
endpoints.MapGet("/auth/ping", HandlePing);
return endpoints;
}
/// <summary>
/// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's
/// cookie session is still valid and <c>401</c> once it has lapsed
/// server-side. See CentralUI-020.
/// </summary>
/// <param name="context">The current HTTP context used to check authentication state and write the response.</param>
public static Task HandlePing(HttpContext context)
{
context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true
? StatusCodes.Status200OK
: StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
/// <summary>
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
/// CentralUI-005: deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
/// Session expiry is owned by the cookie authentication middleware's sliding
/// window (configured in <c>ZB.MOM.WW.ScadaBridge.Security</c>'s <c>AddCookie</c>:
/// <c>ExpireTimeSpan</c> = the idle timeout, <c>SlidingExpiration = true</c>).
/// Setting a fixed <c>ExpiresUtc</c> here would re-impose a hard absolute cap
/// that overrides the sliding window and contradicts the documented
/// "sliding refresh, 30-minute idle timeout" policy. <see cref="AuthenticationProperties.IsPersistent"/>
/// is true so the cookie survives a browser restart within the idle window;
/// <see cref="AuthenticationProperties.AllowRefresh"/> is left unset (null)
/// so the middleware is free to slide the expiry on activity.
/// </summary>
public static AuthenticationProperties BuildSignInProperties() => new()
{
IsPersistent = true
};
}
@@ -0,0 +1,46 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// <summary>
/// Claim-lookup helpers for the Central UI. CentralUI-024: claim types are owned
/// by <see cref="JwtTokenService"/> (the single source of truth). These helpers
/// resolve them through the <c>JwtTokenService</c> constants so a rename there
/// propagates here instead of silently breaking ten copy-pasted call sites.
/// </summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>Fallback returned when no username claim is present.</summary>
public const string UnknownUser = "unknown";
/// <summary>
/// The audit username for <paramref name="principal"/>, or
/// <see cref="UnknownUser"/> when the claim is absent.
/// </summary>
/// <param name="principal">The claims principal to read the username from.</param>
public static string GetUsername(this ClaimsPrincipal principal)
=> principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? UnknownUser;
/// <summary>
/// The display name for <paramref name="principal"/>, or <c>null</c> when
/// the claim is absent.
/// </summary>
/// <param name="principal">The claims principal to read the display name from.</param>
public static string? GetDisplayName(this ClaimsPrincipal principal)
=> principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
/// <summary>
/// Resolves the current user's audit username from the auth state provider.
/// Replaces the <c>GetCurrentUserAsync</c> helper that was copy-pasted into
/// ten components (CentralUI-024).
/// </summary>
/// <param name="authStateProvider">The Blazor authentication state provider to read from.</param>
public static async Task<string> GetCurrentUsernameAsync(
this AuthenticationStateProvider authStateProvider)
{
var authState = await authStateProvider.GetAuthenticationStateAsync();
return authState.User.GetUsername();
}
}
@@ -0,0 +1,48 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Http;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// <summary>
/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state.
/// <para>
/// The cookie middleware validates and decrypts the cookie during the initial
/// HTTP request that establishes the Blazor circuit. This provider is registered
/// <c>Scoped</c>, so it is constructed within that request's DI scope while
/// <see cref="IHttpContextAccessor.HttpContext"/> is still valid. We snapshot
/// the authenticated principal <b>once</b> in the constructor and serve that
/// snapshot for the lifetime of the circuit.
/// </para>
/// <para>
/// We must NOT read <see cref="IHttpContextAccessor"/> on every
/// <see cref="GetAuthenticationStateAsync"/> call (CentralUI-004): for the
/// lifetime of a long-lived SignalR circuit <c>HttpContext</c> is <c>null</c>
/// (or, worse, a stale/foreign context), so a later re-evaluation —
/// e.g. <c>&lt;AuthorizeView&gt;</c> re-rendering — would otherwise see an
/// unauthenticated principal and render the wrong UI.
/// </para>
/// </summary>
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
{
private readonly Task<AuthenticationState> _circuitAuthState;
/// <summary>
/// Snapshots the authenticated principal from the current HTTP context for use throughout the circuit lifetime.
/// </summary>
/// <param name="httpContextAccessor">Accessor used to read the initial HTTP context principal.</param>
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
{
// Snapshot the principal at circuit-construction time. HttpContext is
// valid here (initial HTTP request) and will not be afterwards.
var user = httpContextAccessor.HttpContext?.User
?? new ClaimsPrincipal(new ClaimsIdentity());
_circuitAuthState = Task.FromResult(new AuthenticationState(user));
}
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> _circuitAuthState;
}
@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// <summary>
/// Resolves the set of sites the current user is permitted to operate on, from
/// the <c>SiteId</c> claims attached at login (CentralUI-002).
/// <para>
/// The design (Component-CentralUI, CLAUDE.md "Security &amp; Auth") makes the
/// Deployment role site-scoped: a Deployment user mapped through an LDAP group
/// with site-scope rules carries one <see cref="JwtTokenService.SiteIdClaimType"/>
/// claim per permitted site (the claim value is the integer <c>Site.Id</c>).
/// A Deployment user with no <c>SiteId</c> claim — and any Admin/Design user — is
/// system-wide.
/// </para>
/// <para>
/// Deployment and Monitoring pages must filter every site/instance list through
/// <see cref="FilterSitesAsync"/> and re-check <see cref="IsSiteAllowedAsync"/>
/// before any cross-site command, so a scoped user cannot view or act on sites
/// outside their grant.
/// </para>
/// </summary>
public sealed class SiteScopeService
{
private readonly AuthenticationStateProvider _authStateProvider;
private (bool IsSystemWide, IReadOnlySet<int> Sites)? _cached;
/// <summary>Initializes a new instance of <see cref="SiteScopeService"/>.</summary>
/// <param name="authStateProvider">The Blazor authentication state provider used to read the current user's claims.</param>
public SiteScopeService(AuthenticationStateProvider authStateProvider)
{
_authStateProvider = authStateProvider;
}
/// <summary>
/// True when the user is not restricted to a site subset (no <c>SiteId</c>
/// claims). System-wide users see and act on every site.
/// </summary>
public async Task<bool> IsSystemWideAsync()
=> (await ResolveAsync()).IsSystemWide;
/// <summary>
/// The set of <c>Site.Id</c> values the user may operate on. Empty for a
/// system-wide user (callers should consult <see cref="IsSystemWideAsync"/>
/// or use the filter/allowed helpers, which already account for that).
/// </summary>
public async Task<IReadOnlySet<int>> PermittedSiteIdsAsync()
=> (await ResolveAsync()).Sites;
/// <summary>
/// Returns the subset of <paramref name="sites"/> the user is permitted to
/// see. A system-wide user gets the full list back unchanged.
/// </summary>
/// <param name="sites">The full set of sites to filter.</param>
public async Task<List<Site>> FilterSitesAsync(IEnumerable<Site> sites)
{
var (isSystemWide, allowed) = await ResolveAsync();
if (isSystemWide)
return sites.ToList();
return sites.Where(s => allowed.Contains(s.Id)).ToList();
}
/// <summary>
/// True when the user may operate on the site with the given <c>Site.Id</c>.
/// Must be re-checked server-side before any mutating cross-site command.
/// </summary>
/// <param name="siteId">The <c>Site.Id</c> to check.</param>
public async Task<bool> IsSiteAllowedAsync(int siteId)
{
var (isSystemWide, allowed) = await ResolveAsync();
return isSystemWide || allowed.Contains(siteId);
}
private async Task<(bool IsSystemWide, IReadOnlySet<int> Sites)> ResolveAsync()
{
if (_cached is { } cached)
return cached;
var state = await _authStateProvider.GetAuthenticationStateAsync();
var siteClaims = state.User.FindAll(JwtTokenService.SiteIdClaimType);
var ids = new HashSet<int>();
foreach (var claim in siteClaims)
{
if (int.TryParse(claim.Value, out var id))
ids.Add(id);
}
// No SiteId claims => system-wide. Absence of scope rules means an
// unrestricted deployer (Security-017 made this service the sole
// site-scoping mechanism — there is no separate handler to mirror).
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
_cached = result;
return result;
}
}
@@ -0,0 +1,42 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
The drawer owns only the offcanvas chrome (backdrop, header, Close buttons);
the single-AuditEvent detail body is delegated to <AuditEventDetail>, which
is shared with the execution-tree node-detail modal. *@
@if (IsOpen && Event is not null)
{
<div class="offcanvas-backdrop fade show" data-test="drawer-backdrop"
@onclick="HandleClose"></div>
<div class="offcanvas offcanvas-end show audit-drilldown-drawer"
tabindex="-1"
style="visibility: visible;"
data-test="audit-drilldown-drawer">
<div class="offcanvas-header border-bottom">
<div>
<div class="text-muted small text-uppercase">Audit event</div>
<h5 class="offcanvas-title mb-0">Audit Event @ShortEventId(Event.EventId)</h5>
</div>
<button type="button" class="btn-close" aria-label="Close"
data-test="drawer-close"
@onclick="HandleClose"></button>
</div>
<div class="offcanvas-body small">
@* Single-row detail body + action buttons — shared component. *@
<AuditEventDetail Event="Event" />
</div>
@* Close button kept at the bottom per form-layout memory. *@
<div class="border-top p-3 d-flex gap-2 flex-wrap drawer-footer">
<button class="btn btn-primary btn-sm ms-auto"
data-test="drawer-close-footer"
@onclick="HandleClose">
Close
</button>
</div>
</div>
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
/// Close buttons; the single-row detail body (read-only fields, conditional
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
/// to <see cref="AuditEventDetail"/>, which is shared with the execution-tree
/// node-detail modal so a row's detail renders identically in either host.
/// The drawer is fully presentational — it has no DB or service dependencies;
/// the host page owns the open/close state.
/// </summary>
public partial class AuditDrilldownDrawer
{
/// <summary>
/// The row to render. When null the drawer renders nothing — the host
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
/// </summary>
[Parameter] public AuditEvent? Event { get; set; }
/// <summary>
/// True when the host wants the drawer visible. We deliberately keep
/// this as a separate parameter from <see cref="Event"/>: an open
/// drawer briefly with a null event renders nothing (the row may still
/// be loading); a closed drawer with a stale event is the resting state.
/// </summary>
[Parameter] public bool IsOpen { get; set; }
/// <summary>
/// Fired when the user dismisses the drawer (close button or backdrop
/// click). The host is expected to flip <see cref="IsOpen"/> to false.
/// </summary>
[Parameter] public EventCallback OnClose { get; set; }
private static string ShortEventId(Guid eventId)
{
// Mirror the "first 8 hex digits" presentation common across the UI.
var n = eventId.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private async Task HandleClose()
{
if (OnClose.HasDelegate)
{
await OnClose.InvokeAsync();
}
}
}
@@ -0,0 +1,15 @@
/* Audit Log drilldown drawer (#23 M7 Bundle C).
The base offcanvas + backdrop classes come from Bootstrap. The local
overrides below pin our preferred width and the footer tint. The body
(pre-block) styles travel with the markup in AuditEventDetail.razor.css. */
.audit-drilldown-drawer {
/* Slightly wider than the parked-messages drawer because audit rows can
carry larger JSON bodies and SQL blocks. Clamp to viewport so narrow
windows still get the close button on screen. */
width: min(720px, 95vw);
}
.audit-drilldown-drawer .drawer-footer {
background-color: var(--bs-tertiary-bg);
}
@@ -0,0 +1,168 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
Extracted from AuditDrilldownDrawer so the drawer and the execution-tree
node-detail modal share one rendering of a row's detail.
All form/field rendering follows the form-layout memory:
read-only fields first (definition list), then subsections stacked,
action buttons at the bottom. *@
@* Read-only field list — primary identification + provenance. *@
<dl class="row mb-3" data-test="drawer-fields">
<dt class="col-4 text-muted fw-normal">Channel / Kind</dt>
<dd class="col-8" data-test="field-Channel">@Event.Channel / @Event.Kind</dd>
<dt class="col-4 text-muted fw-normal">Status</dt>
<dd class="col-8" data-test="field-Status">@Event.Status</dd>
<dt class="col-4 text-muted fw-normal">HttpStatus</dt>
<dd class="col-8 font-monospace" data-test="field-HttpStatus">@(Event.HttpStatus?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Target</dt>
<dd class="col-8" data-test="field-Target">@(Event.Target ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Actor</dt>
<dd class="col-8" data-test="field-Actor">@(Event.Actor ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
<dd class="col-8" data-test="field-SourceSiteId">@(Event.SourceSiteId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceNode</dt>
<dd class="col-8" data-test="field-SourceNode">@(Event.SourceNode ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceInstanceId</dt>
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceScript</dt>
<dd class="col-8" data-test="field-SourceScript">@(Event.SourceScript ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ParentExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ParentExecutionId">@(Event.ParentExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
<dt class="col-4 text-muted fw-normal">IngestedAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-IngestedAtUtc">@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")</dd>
<dt class="col-4 text-muted fw-normal">DurationMs</dt>
<dd class="col-8 font-monospace" data-test="field-DurationMs">@(Event.DurationMs?.ToString() ?? "—")</dd>
</dl>
@* Error subsection — only shown when there is something to report. *@
@if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail))
{
<section class="mb-3" data-test="section-error">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Error</h6>
@if (!string.IsNullOrEmpty(Event.ErrorMessage))
{
<p class="text-danger mb-1">@Event.ErrorMessage</p>
}
@if (!string.IsNullOrEmpty(Event.ErrorDetail))
{
<pre class="bg-light border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
}
</section>
}
@* Request body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.RequestSummary))
{
<section class="mb-3" data-test="section-request">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Request</span>
@if (IsRedacted(Event.RequestSummary))
{
<span data-test="redaction-badge-request"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="request-body">
@RenderBody(Event.RequestSummary!, Event.Channel)
</div>
</section>
}
@* Response body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.ResponseSummary))
{
<section class="mb-3" data-test="section-response">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Response</span>
@if (IsRedacted(Event.ResponseSummary))
{
<span data-test="redaction-badge-response"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="response-body">
@RenderBody(Event.ResponseSummary!, Event.Channel)
</div>
</section>
}
@* Extra is always JSON when present. *@
@if (!string.IsNullOrEmpty(Event.Extra))
{
<section class="mb-3" data-test="section-extra">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Extra</h6>
<pre class="bg-light border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
</section>
}
@* Action buttons at the bottom per form-layout memory. *@
<div class="d-flex gap-2 flex-wrap" data-test="audit-event-detail-actions">
@if (IsApiChannel(Event.Channel))
{
<button class="btn btn-outline-secondary btn-sm"
data-test="copy-as-curl"
@onclick="CopyCurl">
Copy as cURL
</button>
}
@if (Event.CorrelationId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="show-all-events"
@onclick="ShowAllForOperation">
Show all events for this operation
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-this-execution"
@onclick="ViewThisExecution">
View this execution
</button>
}
@if (Event.ParentExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-parent-execution"
@onclick="ViewParentExecution">
View parent execution
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-execution-chain"
@onclick="ViewExecutionChain">
View execution chain
</button>
}
</div>
@@ -0,0 +1,393 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Reusable single-<see cref="AuditEvent"/> detail body (#23 M7 Bundle C /
/// M7-T4..T8). Extracted verbatim from <see cref="AuditDrilldownDrawer"/> so
/// the drawer and the execution-tree node-detail modal render a row's detail
/// identically. Renders the read-only field list, the conditional
/// Error/Request/Response/Extra subsections, and the action buttons (Copy as
/// cURL, Show all events for this operation, View this/parent execution, View
/// execution chain). The component is fully presentational apart from the
/// clipboard interop and drill-back navigation it owns; the host owns its
/// surrounding chrome.
///
/// <para>
/// <b>Body rendering.</b> Request/Response/Extra summaries are strings.
/// JSON is pretty-printed when it parses; falls back to verbatim otherwise.
/// DbOutbound payloads carry a <c>{sql, parameters}</c> JSON shape and get a
/// SQL code block plus a parameter definition list. Syntax highlighting is
/// CSS-class-only (<c>language-sql</c>); no JS library is loaded — Blazor
/// Server + Bootstrap only per the project's UI rules.
/// </para>
///
/// <para>
/// <b>Redaction badges.</b> The audit pipeline replaces redacted values
/// with the literal sentinels <c>&lt;redacted&gt;</c> or
/// <c>&lt;redacted: redactor error&gt;</c> (see Component-AuditLog.md
/// §Redaction). A yellow "Redacted" badge surfaces on a body section when
/// its text contains either sentinel — no un-redaction or counting.
/// </para>
///
/// <para>
/// <b>Copy as cURL.</b> Best-effort: the URL comes from <c>Target</c>;
/// when the RequestSummary parses as <c>{headers, body}</c>, headers are
/// folded into <c>-H</c> flags and the body into <c>--data-raw</c>. The
/// command is written to the system clipboard via
/// <see cref="IJSRuntime.InvokeVoidAsync(string, object?[])"/>. The button
/// is only surfaced for API channels (ApiOutbound / ApiInbound).
/// </para>
///
/// <para>
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
/// the "Show all events" button navigates to
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// button navigates to <c>/audit/log?executionId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
/// — the spawner's id used as the per-run drill-in target. All are deep
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
/// </para>
/// </summary>
public partial class AuditEventDetail
{
[Inject] private IJSRuntime JS { get; set; } = null!;
[Inject] private NavigationManager Navigation { get; set; } = null!;
/// <summary>
/// The row to render. Required and non-null — the host (drawer or modal)
/// only mounts this component once it has a row to show.
/// </summary>
[Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
private const string RedactionSentinel = "<redacted>";
private const string RedactorErrorSentinel = "<redacted: redactor error>";
private static bool IsApiChannel(AuditChannel channel)
=> channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound;
private static string FormatTimestamp(DateTime utc)
{
// Force UTC kind in case the row arrived as Unspecified, then emit
// round-trip ISO-8601 so audit drilldowns are copy-paste safe.
var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc;
return kind.ToString("o", CultureInfo.InvariantCulture);
}
private static bool IsRedacted(string? text)
{
if (string.IsNullOrEmpty(text)) return false;
return text.Contains(RedactionSentinel, StringComparison.Ordinal)
|| text.Contains(RedactorErrorSentinel, StringComparison.Ordinal);
}
/// <summary>
/// Channel-aware body renderer. DbOutbound bodies that parse as
/// <c>{sql, parameters}</c> get a SQL block + parameter list; anything
/// else falls back to JSON-pretty-print, then plain-text verbatim.
/// </summary>
private RenderFragment RenderBody(string body, AuditChannel channel) => builder =>
{
// DbOutbound special-case: try to extract {sql, parameters}.
if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters))
{
builder.OpenElement(0, "pre");
builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre");
builder.OpenElement(2, "code");
// Highlighting is CSS-class-only — no JS library is loaded.
builder.AddAttribute(3, "class", "language-sql");
builder.AddContent(4, sql);
builder.CloseElement();
builder.CloseElement();
if (parameters is not null && parameters.Count > 0)
{
builder.OpenElement(10, "dl");
builder.AddAttribute(11, "class", "row mb-0 small");
builder.AddAttribute(12, "data-test", "sql-parameters");
// The analyzer (ASP0006) requires literal sequence numbers
// inside a render fragment. We delegate parameter rendering
// to a helper fragment that uses a stable @key per entry,
// so per-row diffing stays correct even though the outer
// sequence number is fixed.
builder.AddContent(13, BuildSqlParameterRows(parameters));
builder.CloseElement();
}
return;
}
// Generic JSON pretty-print path.
if (TryPrettyPrintJson(body, out var pretty))
{
builder.OpenElement(20, "pre");
builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json");
builder.AddContent(22, pretty);
builder.CloseElement();
return;
}
// Fallback: verbatim. Wrapping in <pre> preserves whitespace, which
// is useful when the body is multi-line plain text or a partial JSON.
builder.OpenElement(30, "pre");
builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
builder.AddContent(32, body);
builder.CloseElement();
};
private static RenderFragment BuildSqlParameterRows(List<KeyValuePair<string, string>> parameters) => builder =>
{
foreach (var kv in parameters)
{
// Literal sequence numbers (ASP0006) + per-element SetKey so
// Blazor's diff is still keyed on parameter name. The "0" base
// is fine here — each loop iteration produces a disjoint
// dt/dd pair, and the diff keys on @key, not sequence.
builder.OpenElement(0, "dt");
builder.SetKey($"dt-{kv.Key}");
builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
builder.AddContent(2, kv.Key);
builder.CloseElement();
builder.OpenElement(3, "dd");
builder.SetKey($"dd-{kv.Key}");
builder.AddAttribute(4, "class", "col-8 font-monospace");
builder.AddContent(5, kv.Value);
builder.CloseElement();
}
};
private static bool TryPrettyPrintJson(string text, out string formatted)
{
formatted = text;
try
{
using var doc = JsonDocument.Parse(text);
formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
return true;
}
catch (JsonException)
{
return false;
}
}
private static string PrettyPrintJson(string text)
=> TryPrettyPrintJson(text, out var pretty) ? pretty : text;
/// <summary>
/// Best-effort parse of a DbOutbound <c>{sql, parameters}</c> body.
/// Returns true only when the JSON has a string <c>sql</c> property;
/// <c>parameters</c> is treated as an optional object whose values
/// stringify to scalar text.
/// </summary>
private static bool TryParseDbBody(string text, out string sql, out List<KeyValuePair<string, string>>? parameters)
{
sql = string.Empty;
parameters = null;
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
return false;
sql = sqlProp.GetString() ?? string.Empty;
if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
&& paramsProp.ValueKind == JsonValueKind.Object)
{
parameters = new List<KeyValuePair<string, string>>();
foreach (var p in paramsProp.EnumerateObject())
{
parameters.Add(new KeyValuePair<string, string>(p.Name, StringifyJsonValue(p.Value)));
}
}
return true;
}
catch (JsonException)
{
return false;
}
}
private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Null => "null",
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Number => value.GetRawText(),
_ => value.GetRawText(),
};
private async Task CopyCurl()
{
var curl = BuildCurlCommand(Event);
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
}
catch
{
// Clipboard interop can fail (denied permission, prerender, etc.).
// The component stays mounted; the failure surfaces in the dev
// console only — we deliberately do not toast here because the
// parent page owns toast state.
}
}
private void ShowAllForOperation()
{
if (Event.CorrelationId is not { } corr) return;
var uri = $"/audit/log?correlationId={corr}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
/// — the universal per-run correlation value, distinct from the per-operation
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
/// which the page parses on init and auto-loads. The button is only rendered
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewThisExecution()
{
if (Event.ExecutionId is not { } exec) return;
var uri = $"/audit/log?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to the spawner execution: a routed (child) row carries a non-null
/// <see cref="AuditEvent.ParentExecutionId"/>. Navigates to
/// <c>/audit/log?executionId={ParentExecutionId}</c> so the user sees the
/// spawner execution's own rows — the parent's id becomes the <c>?executionId=</c>
/// drill-in target. The button is only rendered when
/// <see cref="AuditEvent.ParentExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewParentExecution()
{
if (Event.ParentExecutionId is not { } parentExec) return;
var uri = $"/audit/log?executionId={parentExec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
/// feature, Task 10). Navigates to
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
/// resolves the whole chain rooted at the topmost ancestor and renders it
/// expandably, with this row's execution highlighted. The button is only
/// rendered when <see cref="AuditEvent.ExecutionId"/> is non-null, so this
/// is total.
/// </summary>
private void ViewExecutionChain()
{
if (Event.ExecutionId is not { } exec) return;
var uri = $"/audit/execution-tree?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Build a cURL command from an audit event. The URL comes from
/// <c>Target</c>; when the RequestSummary parses as
/// <c>{headers, body, method?}</c>, headers fold into <c>-H</c> flags
/// and the body into <c>--data-raw</c>. Default method is POST for
/// outbound audit rows — the audit pipeline does not always capture
/// the verb explicitly.
/// </summary>
private static string BuildCurlCommand(AuditEvent ev)
{
var sb = new StringBuilder();
sb.Append("curl");
string method = "POST";
List<KeyValuePair<string, string>>? headers = null;
string? body = null;
if (!string.IsNullOrEmpty(ev.RequestSummary))
{
TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
}
sb.Append(' ').Append("-X ").Append(method);
if (headers is not null)
{
foreach (var (name, value) in headers)
{
sb.Append(' ').Append("-H ");
sb.Append(QuoteShellArg($"{name}: {value}"));
}
}
if (!string.IsNullOrEmpty(body))
{
sb.Append(' ').Append("--data-raw ");
sb.Append(QuoteShellArg(body!));
}
var url = ev.Target ?? string.Empty;
sb.Append(' ').Append(QuoteShellArg(url));
return sb.ToString();
}
private static void TryExtractCurlPartsFromJson(
string requestSummary,
ref string method,
ref List<KeyValuePair<string, string>>? headers,
ref string? body)
{
try
{
using var doc = JsonDocument.Parse(requestSummary);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
{
method = m.GetString() ?? method;
}
if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
{
headers = new List<KeyValuePair<string, string>>();
foreach (var h in hs.EnumerateObject())
{
var value = h.Value.ValueKind == JsonValueKind.String
? h.Value.GetString() ?? string.Empty
: h.Value.GetRawText();
headers.Add(new KeyValuePair<string, string>(h.Name, value));
}
}
if (doc.RootElement.TryGetProperty("body", out var b))
{
body = b.ValueKind == JsonValueKind.String
? b.GetString()
: b.GetRawText();
}
}
catch (JsonException)
{
// RequestSummary wasn't the expected {headers, body} shape —
// just produce a bare cURL with no body/headers.
}
}
/// <summary>
/// Quote a single shell argument with single quotes, escaping embedded
/// single quotes via the standard <c>'\''</c> idiom. This is the same
/// quoting strategy curl examples use across man pages.
/// </summary>
private static string QuoteShellArg(string value)
{
if (string.IsNullOrEmpty(value)) return "''";
var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
return $"'{escaped}'";
}
}
@@ -0,0 +1,30 @@
/* Body-specific styles for the shared single-AuditEvent detail
(#23 M7 Bundle C). Moved here from AuditDrilldownDrawer.razor.css so the
scoped CSS travels with the markup — these rules apply wherever the
detail body is hosted (drilldown drawer or execution-tree node modal). */
.drawer-pre {
/* Wrap long lines and bound the per-block height so the host body stays
scrollable end-to-end instead of pushing the action buttons below the
fold. */
white-space: pre-wrap;
word-break: break-word;
max-height: 320px;
overflow-y: auto;
margin: 0;
font-size: 0.8125rem;
}
.drawer-pre.json {
/* JSON blocks get a faint left rule so they read as quoted material. */
border-left: 3px solid var(--bs-info-border-subtle);
}
.drawer-pre code.language-sql {
/* CSS-only highlight cue: SQL stays mono with a hint of bold weight on
a slightly different background so the SQL block reads distinct from
generic JSON pretty-prints without loading a syntax-highlighter JS
library. */
font-family: var(--bs-font-monospace);
color: var(--bs-emphasis-color);
}
@@ -0,0 +1,173 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.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">
@* All filters sit in one wrapped row. Kind / Status / Site use compact
MultiSelectDropdown controls; Channel is a single-select because the
Kind options narrow to the chosen channel — so the bar stays a row or
two tall instead of four stacked blocks of chip buttons. *@
<div class="row g-2 align-items-end">
@* Single-select: one channel at a time, so the Kind options below
narrow cleanly to that channel. "All channels" clears it. *@
<div class="col-auto" data-test="filter-channel">
<label class="form-label small mb-1" for="audit-channel">Channel</label>
<select id="audit-channel" data-test="filter-channel-select"
class="form-select form-select-sm" @bind="SelectedChannel">
<option value="">All channels</option>
@foreach (var channel in _channels)
{
<option value="@channel">@channel</option>
}
</select>
</div>
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@
<div class="col-auto" data-test="filter-kind">
<label class="form-label small mb-1">Kind</label>
<div>
<MultiSelectDropdown TValue="AuditKind"
Items="_model.VisibleKinds()"
Selected="_model.Kinds"
DataTest="filter-kind-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-status">
<label class="form-label small mb-1">Status</label>
<div>
<MultiSelectDropdown TValue="AuditStatus"
Items="_statuses"
Selected="_model.Statuses"
DataTest="filter-status-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-site">
<label class="form-label small mb-1">Site</label>
<div>
<MultiSelectDropdown TValue="string"
Items="_siteIds"
Selected="_model.SiteIdentifiers"
Display="SiteName"
EmptyText="No sites available"
DataTest="filter-site-ms" />
</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"
@bind="_model.TimeRange">
<option value="@AuditTimeRangePreset.Last5Minutes">Last 5 min</option>
<option value="@AuditTimeRangePreset.LastHour">Last 1h</option>
<option value="@AuditTimeRangePreset.Last24Hours">Last 24h</option>
<option value="@AuditTimeRangePreset.Custom">Custom</option>
</select>
</div>
@* Custom datetime range; only the pickers are conditional, the wrapper is
always emitted so tests can find it. *@
<div class="col-auto" data-test="filter-custom-range">
@if (_model.TimeRange == AuditTimeRangePreset.Custom)
{
<div class="d-flex gap-1 align-items-end">
<div>
<label class="form-label small mb-1" for="audit-from">From (UTC)</label>
<input id="audit-from" type="datetime-local" class="form-control form-control-sm"
@bind="_model.CustomFromUtc" />
</div>
<div>
<label class="form-label small mb-1" for="audit-to">To (UTC)</label>
<input id="audit-to" type="datetime-local" class="form-control form-control-sm"
@bind="_model.CustomToUtc" />
</div>
</div>
}
else
{
<span class="text-muted small">Window: @TimeRangeLabel(_model.TimeRange)</span>
}
</div>
<div class="col-auto" data-test="filter-instance">
<label class="form-label small mb-1" for="audit-instance">Instance</label>
<input id="audit-instance" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.InstanceSearch" />
</div>
<div class="col-auto" data-test="filter-script">
<label class="form-label small mb-1" for="audit-script">Script</label>
<input id="audit-script" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.ScriptSearch" />
</div>
<div class="col-auto" data-test="filter-target">
<label class="form-label small mb-1" for="audit-target">Target</label>
<input id="audit-target" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.TargetSearch" />
</div>
<div class="col-auto" data-test="filter-actor">
<label class="form-label small mb-1" for="audit-actor">Actor</label>
<input id="audit-actor" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.ActorSearch" />
</div>
@* ExecutionId is an exact-match Guid filter — the operator pastes the
universal per-run correlation value. Lax-parsed in ToFilter so a
blank/malformed paste simply drops the constraint. *@
<div class="col-auto" data-test="filter-execution-id">
<label class="form-label small mb-1" for="audit-execution-id">Execution ID</label>
<input id="audit-execution-id" type="text"
class="form-control form-control-sm font-monospace"
placeholder="paste GUID…" @bind="_model.ExecutionId" />
</div>
@* ParentExecutionId is an exact-match Guid filter — the operator pastes
the spawner execution's id to find every run it spawned. Lax-parsed
in ToFilter, exactly like ExecutionId above. *@
<div class="col-auto" data-test="filter-parent-execution-id">
<label class="form-label small mb-1" for="audit-parent-execution-id">Parent execution ID</label>
<input id="audit-parent-execution-id" type="text"
class="form-control form-control-sm font-monospace"
placeholder="paste GUID…" @bind="_model.ParentExecutionId" />
</div>
<div class="col-auto" data-test="filter-errors-only">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="audit-errors-only"
@bind="_model.ErrorsOnly" />
<label class="form-check-label small" for="audit-errors-only">Errors only</label>
</div>
</div>
<div class="col-auto ms-auto">
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="ClearFilters" data-test="filter-clear">Clear</button>
<button class="btn btn-primary btn-sm"
@onclick="Apply" data-test="filter-apply">Apply</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,210 @@
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls
/// — Channel as a single-select (one channel at a time, so the Kind options
/// narrow to it cleanly); Kind / Status / Site as compact
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls; plus the time range, free-text searches and the Errors-only
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
/// dimensions map through to the filter's list fields; see
/// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
/// </summary>
public partial class AuditFilterBar
{
private readonly AuditQueryModel _model = new();
private List<Site> _sites = new();
/// <summary>Channel options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditChannel> _channels = Enum.GetValues<AuditChannel>();
/// <summary>Status options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditStatus> _statuses = Enum.GetValues<AuditStatus>();
/// <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="ZB.MOM.WW.ScadaBridge.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
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Services.IAuditLogQueryService"/>.
/// </summary>
[Parameter] public EventCallback<AuditLogQueryFilter> OnFilterChanged { get; set; }
/// <summary>
/// Test seam: overriding "now" is needed to make the time-range collapse tests
/// stable in unit suites. Production callers leave this null and the model
/// uses <see cref="DateTime.UtcNow"/>.
/// </summary>
[Parameter] public Func<DateTime>? NowUtcProvider { get; set; }
/// <summary>
/// Bundle D drill-in seam (#23 M7-T10..T12). When set on first render,
/// pre-populates the Instance free-text input. Instance is UI-only — the
/// repository filter contract has no instance column — so this flows in
/// through a separate parameter rather than the <see cref="AuditLogQueryFilter"/>
/// the parent page passes to the grid.
/// </summary>
[Parameter] public string? InitialInstanceSearch { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
// One-shot prefill from a drill-in deep link. Subsequent parameter changes
// do NOT overwrite user input — the field is owned by the operator after
// first render.
if (!string.IsNullOrWhiteSpace(InitialInstanceSearch))
{
_model.InstanceSearch = InitialInstanceSearch.Trim();
}
// Populate the Site dropdown at component init. Failure is non-fatal — the
// dropdown just shows "No sites available." Sites are listed by Name to
// match operator expectations from the Notification Report.
try
{
var sites = await SiteRepository.GetAllSitesAsync();
_sites = sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
catch
{
// Swallowed: filter bar still renders without the Site options. The page
// surfaces site-load errors elsewhere (the grid query path).
_sites = new();
}
_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>
/// Single-select Channel binding for the filter bar. The Audit Log filters one
/// channel at a time so the Kind options narrow cleanly to it; the model still
/// stores the selection as a set (0 or 1 entry) so <see cref="AuditQueryModel.ToFilter"/>
/// and <see cref="AuditQueryModel.VisibleKinds"/> are unchanged. <c>null</c> = all channels.
/// </summary>
private AuditChannel? SelectedChannel
{
get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
set
{
_model.Channels.Clear();
if (value is { } channel)
{
_model.Channels.Add(channel);
}
OnChannelsChanged();
}
}
/// <summary>
/// Runs after the Channel selection changes. Drops any Kind selections that fell
/// outside the new visible set — without this, changing the channel could leave
/// stale Kind selections that no longer match any visible option.
/// </summary>
private void OnChannelsChanged()
{
var visible = _model.VisibleKinds().ToHashSet();
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
}
/// <summary>Display label for a site identifier — its friendly Name, id as fallback.</summary>
private string SiteName(string siteIdentifier)
{
var site = _sites.FirstOrDefault(s =>
string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
return site?.Name ?? siteIdentifier;
}
private void ClearFilters()
{
_model.Channels.Clear();
_model.Kinds.Clear();
_model.Statuses.Clear();
_model.SiteIdentifiers.Clear();
_model.SourceNodes.Clear();
_model.TimeRange = AuditTimeRangePreset.LastHour;
_model.CustomFromUtc = null;
_model.CustomToUtc = null;
_model.InstanceSearch = string.Empty;
_model.ScriptSearch = string.Empty;
_model.TargetSearch = string.Empty;
_model.ActorSearch = string.Empty;
_model.ExecutionId = string.Empty;
_model.ParentExecutionId = string.Empty;
_model.ErrorsOnly = false;
}
private async Task Apply()
{
// CentralUI-026: <input type="datetime-local"> binds with DateTimeKind.Unspecified
// — the value is the user's browser-local wall-clock. Tag it as Local then convert
// to UTC before the model emits the filter, otherwise a non-UTC operator's window
// is silently shifted by their UTC offset. Done on a swap-and-restore basis so the
// bound inputs still show the user's local picks on the next render.
var originalFrom = _model.CustomFromUtc;
var originalTo = _model.CustomToUtc;
try
{
_model.CustomFromUtc = LocalInputToUtc(originalFrom);
_model.CustomToUtc = LocalInputToUtc(originalTo);
var now = NowUtcProvider?.Invoke() ?? DateTime.UtcNow;
var filter = _model.ToFilter(now);
await OnFilterChanged.InvokeAsync(filter);
}
finally
{
_model.CustomFromUtc = originalFrom;
_model.CustomToUtc = originalTo;
}
}
/// <summary>
/// Converts a value bound from <c>&lt;input type="datetime-local"&gt;</c> (which Blazor
/// surfaces as <see cref="DateTimeKind.Unspecified"/>) into UTC. The input represents
/// the operator's browser-local wall-clock, so we must tag it <see cref="DateTimeKind.Local"/>
/// before <see cref="DateTime.ToUniversalTime"/> can do anything meaningful.
/// </summary>
private static DateTime? LocalInputToUtc(DateTime? value) =>
value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime()
: (DateTime?)null;
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
{
AuditTimeRangePreset.Last5Minutes => "now 5 min → now",
AuditTimeRangePreset.LastHour => "now 1h → now",
AuditTimeRangePreset.Last24Hours => "now 24h → now",
_ => "—",
};
}
@@ -0,0 +1,227 @@
using System.Collections.Immutable;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// UI-side binding model for <see cref="AuditFilterBar"/> (#23 M7-T2).
///
/// <para>
/// The model mirrors <see cref="AuditLogQueryFilter"/> but allows multi-select chip
/// state for Channel / Kind / Status / Site (each a <see cref="HashSet{T}"/>) plus
/// extra UI-only fields the underlying filter does not carry: the Errors-only toggle,
/// the time-range preset, and free-text Instance / Script searches.
/// </para>
///
/// <para>
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is multi-value
/// per dimension: the chip multi-selects map straight through to the
/// <c>Channels</c> / <c>Kinds</c> / <c>Statuses</c> / <c>SourceSiteIds</c> filter
/// lists when the model is published via <see cref="ToFilter"/> — an empty set means
/// "do not constrain". Instance and Script free-text remain UI-only: the underlying
/// filter has no matching columns, so they are dropped when the model is published.
/// </para>
///
/// <para>
/// The Errors-only toggle is a convenience: when true AND no explicit Status chips
/// are selected, <see cref="ToFilter"/> targets the full error-status set
/// {<see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
/// <see cref="AuditStatus.Discarded"/>}. When Status chips ARE selected the toggle
/// is a no-op — the explicit Status chips win.
/// </para>
/// </summary>
public sealed class AuditQueryModel
{
/// <summary>Selected channel filter chips; empty means all channels.</summary>
public HashSet<AuditChannel> Channels { get; } = new();
/// <summary>Selected kind filter chips; empty means all kinds.</summary>
public HashSet<AuditKind> Kinds { get; } = new();
/// <summary>Selected status filter chips; empty means all statuses.</summary>
public HashSet<AuditStatus> Statuses { get; } = new();
/// <summary>Selected source-site identifier chips; empty means all sites.</summary>
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);
/// <summary>Selected time-range preset controlling which historical window is queried.</summary>
public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour;
/// <summary>Custom start of the time window; used only when <see cref="TimeRange"/> is <see cref="AuditTimeRangePreset.Custom"/>.</summary>
public DateTime? CustomFromUtc { get; set; }
/// <summary>Custom end of the time window; used only when <see cref="TimeRange"/> is <see cref="AuditTimeRangePreset.Custom"/>.</summary>
public DateTime? CustomToUtc { get; set; }
/// <summary>Free-text filter applied to instance names (UI-only; dropped when converting to <see cref="AuditLogQueryFilter"/>).</summary>
public string InstanceSearch { get; set; } = string.Empty;
/// <summary>Free-text filter applied to script names (UI-only; dropped when converting to <see cref="AuditLogQueryFilter"/>).</summary>
public string ScriptSearch { get; set; } = string.Empty;
/// <summary>Free-text filter applied to the target field (external system / DB name / notification list).</summary>
public string TargetSearch { get; set; } = string.Empty;
/// <summary>Free-text filter applied to the actor field (instance or inbound API key name).</summary>
public string ActorSearch { get; set; } = string.Empty;
/// <summary>
/// Paste-in ExecutionId filter — the operator pastes the universal per-run
/// correlation Guid. Stored as free text; <see cref="ToFilter"/> lax-parses it
/// through <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or
/// unparseable value simply yields no constraint.
/// </summary>
public string ExecutionId { get; set; } = string.Empty;
/// <summary>
/// Paste-in ParentExecutionId filter — the operator pastes the spawner
/// execution's Guid to find every run it spawned. Stored as free text;
/// <see cref="ToFilter"/> lax-parses it through
/// <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or unparseable
/// value simply yields no constraint, mirroring <see cref="ExecutionId"/>.
/// </summary>
public string ParentExecutionId { get; set; } = string.Empty;
/// <summary>When true and no explicit status chips are selected, the filter targets the full non-success status set.</summary>
public bool ErrorsOnly { get; set; }
/// <summary>
/// Maps each channel to the kinds it can emit (per Component-AuditLog.md §4).
/// <c>CachedSubmit</c> and <c>CachedResolve</c> appear under both
/// <see cref="AuditChannel.ApiOutbound"/> and <see cref="AuditChannel.DbOutbound"/>
/// because the cached-call lifecycle is channel-agnostic at submit/resolve time.
/// Used by the filter bar to narrow the Kind chip list once Channels are picked.
/// </summary>
public static readonly IReadOnlyDictionary<AuditChannel, ImmutableList<AuditKind>> KindsByChannel =
new Dictionary<AuditChannel, ImmutableList<AuditKind>>
{
[AuditChannel.ApiOutbound] = ImmutableList.Create(
AuditKind.ApiCall, AuditKind.ApiCallCached,
AuditKind.CachedSubmit, AuditKind.CachedResolve),
[AuditChannel.DbOutbound] = ImmutableList.Create(
AuditKind.DbWrite, AuditKind.DbWriteCached,
AuditKind.CachedSubmit, AuditKind.CachedResolve),
[AuditChannel.Notification] = ImmutableList.Create(
AuditKind.NotifySend, AuditKind.NotifyDeliver),
[AuditChannel.ApiInbound] = ImmutableList.Create(
AuditKind.InboundRequest, AuditKind.InboundAuthFailure),
};
/// <summary>
/// Returns the kinds visible in the Kind chip list given the currently selected
/// Channels. With no Channel selected, all 10 kinds are visible (no narrowing).
/// With one or more Channels selected, the union of the channel-specific kind
/// lists is returned (deduplicated and order-stable on first-seen).
/// </summary>
public IReadOnlyList<AuditKind> VisibleKinds()
{
if (Channels.Count == 0)
{
return Enum.GetValues<AuditKind>();
}
var seen = new HashSet<AuditKind>();
var result = new List<AuditKind>();
foreach (var ch in Channels)
{
if (!KindsByChannel.TryGetValue(ch, out var kinds))
{
continue;
}
foreach (var k in kinds)
{
if (seen.Add(k))
{
result.Add(k);
}
}
}
return result;
}
/// <summary>
/// Publishes this UI model as the repository's multi-value filter: each chip
/// multi-select maps straight through to its filter list (an empty set yields
/// <c>null</c> — "do not constrain"). See class doc for the Errors-only rule.
/// </summary>
/// <param name="utcNow">The current UTC timestamp used to compute relative time-range windows.</param>
/// <returns>A populated <see cref="AuditLogQueryFilter"/> ready for the repository.</returns>
public AuditLogQueryFilter ToFilter(DateTime utcNow)
{
var statuses = ResolveStatuses();
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
// Lax-parse the pasted ExecutionId — blank or malformed text yields no
// constraint rather than an error, mirroring the optional-filter contract.
Guid? executionId = Guid.TryParse(ExecutionId, out var parsedExecutionId)
? parsedExecutionId
: null;
// Same lax-parse contract for the pasted ParentExecutionId.
Guid? parentExecutionId = Guid.TryParse(ParentExecutionId, out var parsedParentExecutionId)
? parsedParentExecutionId
: null;
return new AuditLogQueryFilter(
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
Statuses: statuses,
SourceSiteIds: SiteIdentifiers.Count > 0 ? SiteIdentifiers.ToArray() : null,
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc,
ToUtc: toUtc,
SourceNodes: SourceNodes.Count > 0 ? SourceNodes.ToArray() : null);
}
/// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>
private static readonly AuditStatus[] ErrorStatuses =
{ AuditStatus.Failed, AuditStatus.Parked, AuditStatus.Discarded };
private IReadOnlyList<AuditStatus>? ResolveStatuses()
{
if (Statuses.Count > 0)
{
// Explicit chips win — Errors-only is a no-op.
return Statuses.ToArray();
}
if (ErrorsOnly)
{
// Multi-value filter: Errors-only targets the full non-success set.
return ErrorStatuses;
}
return null;
}
private (DateTime? From, DateTime? To) ResolveTimeWindow(DateTime utcNow)
{
return TimeRange switch
{
AuditTimeRangePreset.Last5Minutes => (utcNow.AddMinutes(-5), null),
AuditTimeRangePreset.LastHour => (utcNow.AddHours(-1), null),
AuditTimeRangePreset.Last24Hours => (utcNow.AddHours(-24), null),
AuditTimeRangePreset.Custom => (CustomFromUtc, CustomToUtc),
_ => (null, null),
};
}
}
/// <summary>
/// Time-range presets surfaced in the filter bar. <see cref="Custom"/> reveals the
/// FromUtc / ToUtc datetime pickers; the other presets compute From relative to
/// "now" at the moment Apply is clicked.
/// </summary>
public enum AuditTimeRangePreset
{
Last5Minutes,
LastHour,
Last24Hours,
Custom,
}
@@ -0,0 +1,172 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@inject IAuditLogQueryService QueryService
<div data-test="audit-results-grid">
@if (_error is not null)
{
<div class="alert alert-danger small mb-2">@_error</div>
}
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" @ref="_tableRef">
<thead class="table-light">
<tr>
@foreach (var col in OrderedColumns())
{
// @key keeps Blazor reusing one DOM node per column across
// re-renders (reorder/resize), so audit-grid.js binds drag
// listeners exactly once per <th> and never leaks them onto
// discarded nodes — the __auditGridCellBound guard relies on
// this node stability to be fully sound.
<th class="audit-grid-th"
@key="col.Key"
data-test="col-header-@col.Key"
data-col-key="@col.Key"
style="@ColumnWidthStyle(col.Key)">
@col.Label
<span class="audit-grid-resize-handle"
data-test="col-resize-@col.Key"
aria-hidden="true"></span>
</th>
}
</tr>
</thead>
<tbody>
@if (_rows.Count == 0)
{
<tr>
<td colspan="@OrderedColumns().Count" class="text-muted small text-center py-4">
@if (_loading)
{
<span>Loading…</span>
}
else
{
<span>No audit events match the current filter.</span>
}
</td>
</tr>
}
else
{
@foreach (var row in _rows)
{
<tr @key="row.EventId"
data-test="grid-row-@row.EventId"
class="audit-row"
style="cursor: pointer;"
@onclick="() => HandleRowClick(row)">
@foreach (var col in OrderedColumns())
{
<td class="audit-grid-td" style="@ColumnWidthStyle(col.Key)">
@RenderCell(col.Key, row)
</td>
}
</tr>
}
}
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Page @_pageNumber · @_rows.Count rows</span>
@* CentralUI-032: keyset paging is naturally forward-only, but the
in-component _cursorStack lets the user step back through previous
pages by replaying the prior cursor. The Previous button is gated
on the stack having at least one prior cursor — i.e. we are not on
the first page. *@
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm"
data-test="grid-prev-page"
disabled="@(_loading || !CanGoBack)"
@onclick="PrevPage">Previous page</button>
<button class="btn btn-outline-secondary btn-sm"
data-test="grid-next-page"
disabled="@(_loading || _rows.Count < _pageSize)"
@onclick="NextPage">Next page</button>
</div>
</div>
</div>
@code {
// Compact display for Guid id columns: the first 8 hex digits, mirroring
// the drilldown drawer's ShortEventId presentation. The full value is kept
// in the cell's title attribute so it stays copy-paste accessible.
private static string ShortGuid(Guid value)
{
var n = value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
{
switch (key)
{
case "OccurredAtUtc":
var occurredOffset = new DateTimeOffset(DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc));
<span title="@row.OccurredAtUtc.ToString("u")">
<TimestampDisplay Value="occurredOffset" Format="yyyy-MM-dd HH:mm:ss" />
</span>
break;
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;
case "Kind":
<span class="small">@row.Kind</span>
break;
case "Status":
<span data-test="status-badge-@row.EventId" class="badge @StatusBadgeClass(row.Status)">@row.Status</span>
break;
case "Target":
<span class="small">@(row.Target ?? "—")</span>
break;
case "Actor":
<span class="small">@(row.Actor ?? "—")</span>
break;
case "ExecutionId":
@if (row.ExecutionId is { } executionId)
{
<span class="small font-monospace"
data-test="execution-id-@row.EventId"
title="@executionId">@ShortGuid(executionId)</span>
}
else
{
<span class="small text-muted">—</span>
}
break;
case "ParentExecutionId":
@if (row.ParentExecutionId is { } parentExecutionId)
{
<span class="small font-monospace"
data-test="parent-execution-id-@row.EventId"
title="@parentExecutionId">@ShortGuid(parentExecutionId)</span>
}
else
{
<span class="small text-muted">—</span>
}
break;
case "DurationMs":
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
break;
case "HttpStatus":
<span class="small font-monospace">@(row.HttpStatus?.ToString() ?? "—")</span>
break;
case "ErrorMessage":
<span class="small text-danger" title="@row.ErrorMessage">@TruncateError(row.ErrorMessage)</span>
break;
}
};
}
@@ -0,0 +1,497 @@
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
/// ErrorMessage — plus the ExecutionId per-run correlation column and the
/// ParentExecutionId spawner-correlation column. Talks to
/// <see cref="Services.IAuditLogQueryService"/>
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
/// source without standing up EF Core.
///
/// <para>
/// <b>Column model.</b> Each column has a stable string key. The default
/// visible order is the <see cref="ColumnOrder"/> parameter (or the spec
/// order from Component-AuditLog.md §10 when the parameter is null). On top of
/// that default the grid layers a per-browser override: drag-to-reorder and
/// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column
/// widths to <c>sessionStorage</c>, and the grid restores them on first
/// render. A stored order that names an unknown/removed column degrades
/// gracefully — unknown keys are dropped, missing columns appended in default
/// order — so it never throws.
/// </para>
///
/// <para>
/// <b>Pagination.</b> Each page is a single call to
/// <c>IAuditLogQueryService.QueryAsync</c>. The "Next page" button uses the
/// LAST row of the current page as the keyset cursor — repository orders by
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>, so the oldest row in the visible
/// page becomes <c>AfterOccurredAtUtc</c> + <c>AfterEventId</c> on the next
/// request. The button is disabled when the current page is short (less than
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
/// end" signal for keyset paging without a count query.
/// </para>
///
/// <para>
/// <b>Accessibility.</b> Column resize and reorder are mouse/pointer-only —
/// they use a pointer-driven resize handle and native HTML5 drag-and-drop with
/// no keyboard equivalent and no ARIA for the reorder. This is a conscious
/// scope decision for an internal tool, not an oversight: only the column-
/// <i>customisation</i> gesture is mouse-only. The persisted layout itself
/// renders as plain HTML, so keyboard and assistive-technology users still get
/// a fully readable, navigable grid.
/// </para>
/// </summary>
public partial class AuditResultsGrid : IAsyncDisposable
{
private const int DefaultPageSize = 100;
/// <summary>Minimum persisted column width — mirrors <c>auditGrid.minWidth</c>.</summary>
private const int MinColumnWidthPx = 64;
/// <summary>sessionStorage keys (namespaced under <c>auditGrid:</c> by the JS helper).</summary>
private const string ColumnOrderStorageKey = "columnOrder";
private const string ColumnWidthsStorageKey = "columnWidths";
private readonly List<AuditEvent> _rows = new();
private int _pageNumber = 1;
private bool _loading;
private string? _error;
// CentralUI-032: small in-component stack of prior-page cursors so the user
// can step backwards through results. Each Next push captures the cursor
// that produced the current page (null for page 1) before advancing; each
// Previous pop reloads the page at the recovered cursor. Mirrors the
// SiteCallsReport keyset-paging shape called out in the finding.
private readonly Stack<AuditLogPaging?> _cursorStack = new();
// The cursor that produced the page currently on screen — kept so Next can
// push it before advancing without recomputing it from _rows.
private AuditLogPaging? _currentPaging;
private AuditLogQueryFilter? _activeFilter;
[Inject] private IJSRuntime JS { get; set; } = default!;
private ElementReference _tableRef;
private DotNetObjectReference<AuditResultsGrid>? _selfRef;
// Effective column state. _columnOrder is the live display order (seeded
// from the ColumnOrder parameter / spec default, then overridden by any
// persisted sessionStorage order). _columnWidths holds per-key pixel
// widths from a prior resize; absent keys render at auto width.
private List<string>? _columnOrder;
private readonly Dictionary<string, int> _columnWidths = new();
/// <summary>
/// Filter to apply. When this parameter changes the grid resets to page 1 and
/// reissues the query — that's the contract the parent page relies on so the
/// filter-bar Apply button does not need to drive grid state manually.
/// </summary>
[Parameter] public AuditLogQueryFilter? Filter { get; set; }
/// <summary>Page size. Defaults to 100 to match the service-level default.</summary>
[Parameter] public int PageSize { get; set; } = DefaultPageSize;
/// <summary>
/// Optional column order — list of column keys in display order. When null or
/// empty the default order from Component-AuditLog.md §10 is used. The grid
/// silently drops unknown keys.
/// </summary>
[Parameter] public IReadOnlyList<string>? ColumnOrder { get; set; }
/// <summary>
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
/// drawer. The event payload is the full <see cref="AuditEvent"/>.
/// </summary>
[Parameter] public EventCallback<AuditEvent> OnRowSelected { get; set; }
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
private int _pageSize => Math.Max(1, PageSize);
/// <summary>
/// Default column definitions. The key is the stable identifier (used by
/// <c>data-test</c> + the column-order parameter); the label is the user-facing
/// header text. Mirrors Component-AuditLog.md §10.
/// </summary>
// Label intentionally equals Key for every column today; the separate Label
// field is future-proofing for humanised headers (e.g. "Occurred (UTC)") —
// populating it is a deliberate later change, out of scope here.
private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[]
{
("OccurredAtUtc", "OccurredAtUtc"),
("Site", "Site"),
("Node", "Node"),
("Channel", "Channel"),
("Kind", "Kind"),
("Status", "Status"),
("Target", "Target"),
("Actor", "Actor"),
("ExecutionId", "ExecutionId"),
("ParentExecutionId", "ParentExecutionId"),
("DurationMs", "DurationMs"),
("HttpStatus", "HttpStatus"),
("ErrorMessage", "ErrorMessage"),
};
private IReadOnlyList<(string Key, string Label)> OrderedColumns()
=> ResolveOrder(_columnOrder ?? ColumnOrder);
/// <summary>
/// Resolves a candidate list of column keys into the concrete display
/// columns. Degrades gracefully so a stale persisted order is never fatal:
/// unknown keys are dropped, and any column not named in the candidate
/// list is appended in its default (spec) position. A null/empty candidate
/// yields the full default order.
/// </summary>
private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList<string>? candidate)
{
if (candidate is null || candidate.Count == 0)
{
return AllColumns;
}
var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
var ordered = new List<(string Key, string Label)>(AllColumns.Count);
var seen = new HashSet<string>();
foreach (var key in candidate)
{
// Drop unknown keys (removed/renamed columns) and any duplicates.
if (byKey.TryGetValue(key, out var col) && seen.Add(key))
{
ordered.Add(col);
}
}
// Append any columns the candidate omitted, in default order, so a
// newly-added column still appears after a restore of an older order.
foreach (var col in AllColumns)
{
if (seen.Add(col.Key))
{
ordered.Add(col);
}
}
return ordered;
}
/// <summary>
/// Inline style for a column's cells: emits the <c>--audit-col-width</c>
/// custom property the scoped stylesheet reads, or an empty string when
/// the column has no persisted width (auto layout).
/// </summary>
private string ColumnWidthStyle(string key)
=> _columnWidths.TryGetValue(key, out var width)
? $"--audit-col-width: {width}px;"
: string.Empty;
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
// Reset & reload whenever the filter reference changes. AuditLogQueryFilter
// is a record, so equality-by-value gives us a free "did the user click Apply
// with the same chips?" no-op signal. We pin to ReferenceEquals here so the
// grid reloads only when the parent hands us a new filter instance — the
// page wraps Apply in a fresh allocation, which is the canonical reload signal.
if (!ReferenceEquals(_activeFilter, Filter))
{
_activeFilter = Filter;
_pageNumber = 1;
_rows.Clear();
_cursorStack.Clear();
_currentPaging = null;
if (Filter is not null)
{
await LoadAsync(paging: null);
}
}
}
private async Task NextPage()
{
if (_rows.Count == 0 || _activeFilter is null)
{
return;
}
var last = _rows[^1];
var cursor = new AuditLogPaging(
PageSize: _pageSize,
AfterOccurredAtUtc: last.OccurredAtUtc,
AfterEventId: last.EventId);
// CentralUI-032: remember the cursor that produced the current page so
// a later Previous can navigate back to it. The page-1 entry is pushed
// as null — LoadAsync treats null as "first page" (PageSize-only).
_cursorStack.Push(_currentPaging);
await LoadAsync(cursor);
_pageNumber++;
}
// CentralUI-032: pops the previous-page cursor off the stack and reloads
// at that position. The pop only happens AFTER a successful reload — a
// failed page-fetch leaves the user on the current page with the error
// banner instead of stranding them between pages.
private async Task PrevPage()
{
if (_cursorStack.Count == 0 || _activeFilter is null)
{
return;
}
var prior = _cursorStack.Peek();
await LoadAsync(prior);
if (_error is null)
{
_cursorStack.Pop();
_pageNumber = Math.Max(1, _pageNumber - 1);
}
}
private bool CanGoBack => _cursorStack.Count > 0;
private async Task LoadAsync(AuditLogPaging? paging)
{
if (_activeFilter is null)
{
return;
}
_loading = true;
_error = null;
try
{
var effective = paging ?? new AuditLogPaging(_pageSize);
var page = await QueryService.QueryAsync(_activeFilter, effective);
_rows.Clear();
_rows.AddRange(page);
// Track the cursor that produced the page now on screen so a later
// Next can push it onto the stack before advancing.
_currentPaging = paging;
}
catch (Exception ex)
{
// Surface the error in-place; the grid stays alive so the user can
// adjust the filter and retry without a page refresh.
_error = $"Query failed: {ex.Message}";
}
finally
{
_loading = false;
}
}
private async Task HandleRowClick(AuditEvent row)
{
if (OnRowSelected.HasDelegate)
{
await OnRowSelected.InvokeAsync(row);
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Restore any persisted order + widths first; the StateHasChanged
// inside triggers a re-render so the restored layout is on screen.
await LoadPersistedStateAsync();
_selfRef = DotNetObjectReference.Create(this);
}
// Wire (or re-wire) the JS drag handlers on every render. auditGrid.init
// is idempotent — already-bound cells are skipped, and the .NET
// reference is refreshed — so a re-render after a reorder still leaves
// every header cell wired without leaking handlers.
//
// OnColumnResized/OnColumnReordered both call StateHasChanged(), which
// re-runs this method and calls init again. That repeat call is an
// intentional cheap no-op: the @key-stable <th> nodes plus the
// __auditGridCellBound guard mean init re-scans the header and rebinds
// nothing — so there is deliberately no gating logic here.
if (_selfRef is not null)
{
try
{
await JS.InvokeVoidAsync("auditGrid.init", _tableRef, _selfRef);
}
catch (JSDisconnectedException)
{
// Circuit gone before init completed — nothing to wire.
}
}
}
/// <summary>
/// Reads the persisted column order + widths from <c>sessionStorage</c> and
/// applies them. A missing, empty, or corrupt payload is treated as "no
/// prior state" — the grid keeps its default order/widths and never throws.
/// </summary>
private async Task LoadPersistedStateAsync()
{
var orderJson = await TryLoadAsync(ColumnOrderStorageKey);
var widthsJson = await TryLoadAsync(ColumnWidthsStorageKey);
var changed = false;
if (!string.IsNullOrEmpty(orderJson))
{
try
{
var stored = JsonSerializer.Deserialize<List<string>>(orderJson);
if (stored is { Count: > 0 })
{
// Normalise through ResolveOrder so a stale key never sticks.
_columnOrder = ResolveOrder(stored).Select(c => c.Key).ToList();
changed = true;
}
}
catch (JsonException)
{
// Corrupt payload — ignore, keep the default order.
}
}
if (!string.IsNullOrEmpty(widthsJson))
{
try
{
var stored = JsonSerializer.Deserialize<Dictionary<string, int>>(widthsJson);
if (stored is not null)
{
var validKeys = AllColumns.Select(c => c.Key).ToHashSet();
_columnWidths.Clear();
foreach (var (key, width) in stored)
{
// Drop widths for unknown columns; clamp to the minimum.
if (validKeys.Contains(key))
{
_columnWidths[key] = Math.Max(MinColumnWidthPx, width);
}
}
changed = _columnWidths.Count > 0 || changed;
}
}
catch (JsonException)
{
// Corrupt payload — ignore, keep auto widths.
}
}
if (changed)
{
StateHasChanged();
}
}
private async Task<string?> TryLoadAsync(string key)
{
try
{
return await JS.InvokeAsync<string?>("auditGrid.load", key);
}
catch (JSDisconnectedException)
{
return null;
}
}
/// <summary>
/// JS callback: the user finished resizing a column. Persists the new
/// per-column width and re-renders so the body cells track the header.
/// </summary>
/// <param name="columnKey">The stable key of the resized column.</param>
/// <param name="widthPx">The new column width in pixels.</param>
[JSInvokable]
public async Task OnColumnResized(string columnKey, int widthPx)
{
if (!AllColumns.Any(c => c.Key == columnKey))
{
return;
}
_columnWidths[columnKey] = Math.Max(MinColumnWidthPx, widthPx);
await SaveAsync(ColumnWidthsStorageKey, JsonSerializer.Serialize(_columnWidths));
StateHasChanged();
}
/// <summary>
/// JS callback: the user dropped column <paramref name="fromKey"/> onto the
/// header of <paramref name="toKey"/>. Moves the dragged column into the
/// target's slot, persists the resulting order, and re-renders.
/// </summary>
/// <param name="fromKey">The stable key of the column being dragged.</param>
/// <param name="toKey">The stable key of the target column drop slot.</param>
[JSInvokable]
public async Task OnColumnReordered(string fromKey, string toKey)
{
// Start from the current effective order so successive drags compose.
var order = OrderedColumns().Select(c => c.Key).ToList();
var fromIndex = order.IndexOf(fromKey);
var toIndex = order.IndexOf(toKey);
if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex)
{
return;
}
order.RemoveAt(fromIndex);
// After the removal the target index shifts left by one when the
// dragged column originally sat before it.
if (fromIndex < toIndex)
{
toIndex--;
}
order.Insert(toIndex, fromKey);
_columnOrder = order;
await SaveAsync(ColumnOrderStorageKey, JsonSerializer.Serialize(order));
StateHasChanged();
}
private async Task SaveAsync(string key, string json)
{
try
{
await JS.InvokeVoidAsync("auditGrid.save", key, json);
}
catch (JSDisconnectedException)
{
// Circuit gone — the in-memory state still drives this render.
}
}
/// <summary>
/// Releases the .NET object reference held for JS interop callbacks.
/// </summary>
public ValueTask DisposeAsync()
{
_selfRef?.Dispose();
return ValueTask.CompletedTask;
}
private static string StatusBadgeClass(AuditStatus status) => status switch
{
AuditStatus.Delivered => "badge bg-success",
AuditStatus.Failed or AuditStatus.Parked or AuditStatus.Discarded => "badge bg-danger",
_ => "badge bg-secondary",
};
private static string TruncateError(string? message)
{
if (string.IsNullOrEmpty(message))
{
return "—";
}
const int max = 80;
return message.Length <= max ? message : string.Concat(message.AsSpan(0, max), "…");
}
}
@@ -0,0 +1,82 @@
/* Audit results grid — column resize + reorder UX (#23 follow-ups Task 10).
The base .table classes come from Bootstrap; the rules below add the
resize-handle affordance and the drag-to-reorder drop feedback. The
interaction itself lives in wwwroot/js/audit-grid.js — this file is purely
the visual treatment. Internal-tool aesthetic: subtle, no flashy motion. */
/* A persisted width is delivered as the --audit-col-width custom property on
the <th> and matching <td> cells (set inline by the component / by
audit-grid.js during a drag). When present it pins the cell; when absent
the column falls back to Bootstrap auto-layout. The body cells also clip
overflowing text so a narrowed column stays tidy. */
.audit-grid-th[style*="--audit-col-width"],
.audit-grid-td[style*="--audit-col-width"] {
width: var(--audit-col-width);
min-width: var(--audit-col-width);
max-width: var(--audit-col-width);
}
.audit-grid-td[style*="--audit-col-width"] {
overflow: hidden;
text-overflow: ellipsis;
}
/* The header cell hosts the resize handle on its right edge, so it must be a
positioning context. Padding on the right is trimmed so the 6px handle does
not crowd the label text. */
.audit-grid-th {
position: relative;
padding-right: 0.75rem;
/* The whole header is draggable for reorder — a grab cursor signals it. */
cursor: grab;
user-select: none;
white-space: nowrap;
}
.audit-grid-th:active {
cursor: grabbing;
}
/* V — resize handle. A thin invisible hit-strip on the right edge: 6px wide
for a comfortable grab target, transparent at rest so the header reads
clean. On hover a hairline primary rule fades in via the inset box-shadow
so the affordance is discoverable without being visually noisy. */
.audit-grid-resize-handle {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: col-resize;
/* Sit above the draggable header so a resize never starts a reorder. */
z-index: 1;
transition: box-shadow 0.08s linear, background-color 0.08s linear;
}
.audit-grid-resize-handle:hover {
/* Hairline rule centred on the strip's right edge. */
box-shadow: inset -2px 0 0 -1px rgba(var(--bs-primary-rgb), 0.55);
background-color: rgba(var(--bs-primary-rgb), 0.06);
}
/* While a drag-resize is in progress the column gets a steady primary rule on
its right edge so the user keeps a clear visual anchor. */
.audit-grid-th.resizing {
box-shadow: inset -2px 0 0 0 var(--bs-primary);
}
.audit-grid-th.resizing .audit-grid-resize-handle {
background-color: rgba(var(--bs-primary-rgb), 0.55);
}
/* V — reorder feedback. The dragged header dims slightly; the prospective
drop target gets a left-edge accent rule + a faint info wash, matching the
TreeView drop-target idiom (a quiet, unmistakable cue, not an animation). */
.audit-grid-th.dragging {
opacity: 0.45;
}
.audit-grid-th.drop-target {
background-color: rgba(var(--bs-info-rgb), 0.18);
box-shadow: inset 2px 0 0 0 var(--bs-info);
}
@@ -0,0 +1,112 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@* Execution-Tree Node Detail Modal (Task 3).
Opened from an execution-tree node double-click. Given an ExecutionId it
loads that execution's audit rows and shows a list → per-row detail.
Hand-rolled Bootstrap modal — no bootstrap.bundle.js modal API; visibility
is pure Blazor state (the IsOpen bool) + the d-block/show CSS classes,
mirroring AuditDrilldownDrawer's hand-rolled offcanvas. The per-row detail
body is delegated to the shared <AuditEventDetail>. *@
@if (IsOpen)
{
<div class="modal-backdrop fade show" data-test="execution-detail-backdrop"
@onclick="HandleClose"></div>
<div class="modal fade show d-block execution-detail-modal" tabindex="-1"
data-test="execution-detail-modal" role="dialog"
aria-modal="true" aria-labelledby="execution-detail-modal-title"
@onkeydown="HandleKeyDown">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<div>
<div class="text-muted small text-uppercase">Execution</div>
<h5 id="execution-detail-modal-title"
class="modal-title mb-0 d-flex align-items-baseline gap-2">
<span class="font-monospace">Execution @ShortExecutionId()</span>
@if (!_loading && _error is null)
{
<span class="badge rounded-pill text-bg-secondary fw-normal"
data-test="execution-detail-row-count">
@_rows.Count @(_rows.Count == 1 ? "row" : "rows")
</span>
}
</h5>
</div>
<button type="button" class="btn-close" aria-label="Close"
data-test="execution-detail-close"
@onclick="HandleClose"></button>
</div>
<div class="modal-body small">
@if (_loading)
{
<div class="text-muted py-4 text-center" data-test="execution-detail-loading">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Loading execution rows…
</div>
}
else if (_error is not null)
{
<div class="alert alert-danger mb-0" role="alert"
data-test="execution-detail-error">
@_error
</div>
}
else if (_rows.Count == 0)
{
<div class="text-muted py-4 text-center" data-test="execution-detail-empty">
This execution emitted no audit rows.
</div>
}
else if (_selectedRow is not null)
{
@* Detail view — shared single-row body. *@
@if (_rows.Count > 1)
{
<button type="button"
class="btn btn-link btn-sm px-0 mb-2 execution-detail-back-link"
data-test="execution-detail-back"
@onclick="BackToList">
&larr; Back to rows
</button>
}
<AuditEventDetail Event="_selectedRow" />
}
else
{
@* List view — one button per audit row. *@
<div class="list-group execution-detail-row-list">
@foreach (var row in _rows)
{
<button type="button"
class="list-group-item list-group-item-action d-flex align-items-center gap-3"
data-test="execution-detail-row-@row.EventId"
@onclick="() => SelectRow(row)">
<span class="badge @StatusBadgeClass(row.Status) execution-detail-status">
@row.Status
</span>
<span class="execution-detail-kind fw-semibold">@row.Kind</span>
<span class="text-muted text-truncate flex-grow-1">
@(row.Target ?? "—")
</span>
<span class="text-muted font-monospace small flex-shrink-0">
@FormatTime(row.OccurredAtUtc)
</span>
</button>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm"
data-test="execution-detail-close-footer"
@onclick="HandleClose">
Close
</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,197 @@
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Execution-Tree Node Detail Modal (Execution-Tree Node Detail Modal feature,
/// Task 3). Opened from an execution-tree node double-click: given an
/// <see cref="ExecutionId"/> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
///
/// <para>
/// <b>Chrome.</b> A hand-rolled Bootstrap modal — visibility is pure Blazor
/// state (<see cref="IsOpen"/>) plus the <c>d-block</c>/<c>show</c> CSS classes
/// and a sibling <c>modal-backdrop</c>, mirroring how
/// <see cref="AuditDrilldownDrawer"/> hand-rolls its offcanvas. No
/// <c>bootstrap.bundle.js</c> modal API is used.
/// </para>
///
/// <para>
/// <b>Load timing.</b> The modal queries only on the closed → open transition
/// (detected in <see cref="OnParametersSetAsync"/>), never on every parameter
/// change, so re-renders while open do not re-hit the service.
/// </para>
///
/// <para>
/// <b>States.</b> Two-or-more rows → list view (one button per row, click sets
/// the selected row); exactly one row → opens straight to the detail view;
/// zero rows → a friendly empty state. A query failure degrades to an inline
/// error banner — it is never rethrown, so a transient DB outage cannot kill
/// the SignalR circuit (the same posture as <c>ExecutionTreePage.LoadChainAsync</c>).
/// The per-row detail body is delegated to the shared <see cref="AuditEventDetail"/>.
/// </para>
/// </summary>
public partial class ExecutionDetailModal
{
[Inject] private IAuditLogQueryService AuditLogQueryService { get; set; } = null!;
/// <summary>
/// The execution whose audit rows the modal loads. When null an open modal
/// loads nothing and shows the empty state — the host is expected to pair a
/// non-null id with <see cref="IsOpen"/>.
/// </summary>
[Parameter] public Guid? ExecutionId { get; set; }
/// <summary>
/// True when the host wants the modal visible. The closed → open transition
/// triggers the row load; see <see cref="OnParametersSetAsync"/>.
/// </summary>
[Parameter] public bool IsOpen { get; set; }
/// <summary>
/// Fired when the user dismisses the modal (header X, backdrop click, or
/// footer Close). The host is expected to flip <see cref="IsOpen"/> to false.
/// </summary>
[Parameter] public EventCallback OnClose { get; set; }
// The loaded rows for the current execution; empty until a load completes.
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
// The row whose detail is shown; null = list view.
private AuditEvent? _selectedRow;
private bool _loading;
private string? _error;
// Tracks the previous IsOpen so OnParametersSet can detect the open
// transition and load exactly once per open, not on every parameter change.
private bool _wasOpen;
/// <summary>
/// Page size for the execution-row query. One execution's audit rows are
/// few (cached calls top out around 45 rows); 100 comfortably covers a
/// whole execution without paging.
/// </summary>
private const int RowPageSize = 100;
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
// Load only on the closed → open transition. A re-render while already
// open (or while closed) must not re-hit the service.
if (IsOpen && !_wasOpen)
{
await LoadRowsAsync();
}
_wasOpen = IsOpen;
}
/// <summary>
/// Loads the current execution's audit rows. On success, a single-row
/// result opens straight to the detail view; otherwise the list view shows.
/// A query failure degrades to an inline error banner and is never
/// rethrown — audit drill-in is best-effort and must not kill the circuit.
/// </summary>
private async Task LoadRowsAsync()
{
_loading = true;
_error = null;
_selectedRow = null;
_rows = Array.Empty<AuditEvent>();
if (ExecutionId is null)
{
// Nothing to load — fall through to the empty state.
_loading = false;
return;
}
try
{
// No CancellationToken is passed deliberately: this is a bounded,
// small (~100-row) query for one execution, so the IDisposable/CTS
// machinery is not worth it for a modal. The closed → open guard in
// OnParametersSetAsync cleanly re-loads on the next open if needed.
_rows = await AuditLogQueryService.QueryAsync(
new AuditLogQueryFilter(ExecutionId: ExecutionId.Value),
new AuditLogPaging(PageSize: RowPageSize));
// A single-row execution opens straight to its detail — there is
// no list to choose from.
if (_rows.Count == 1)
{
_selectedRow = _rows[0];
}
}
catch (Exception ex)
{
// Mirror ExecutionTreePage.LoadChainAsync: a transient DB outage
// degrades the modal to an inline error banner rather than killing
// the SignalR circuit. Never rethrow.
_error = $"Could not load this execution's audit rows: {ex.Message}";
_rows = Array.Empty<AuditEvent>();
_selectedRow = null;
}
finally
{
_loading = false;
}
}
private void SelectRow(AuditEvent row) => _selectedRow = row;
private void BackToList() => _selectedRow = null;
private async Task HandleClose()
{
if (OnClose.HasDelegate)
{
await OnClose.InvokeAsync();
}
}
/// <summary>
/// Closes the modal when Escape is pressed, matching the header X, backdrop
/// click, and footer Close affordances. The root <c>.modal</c> div carries
/// <c>tabindex="-1"</c> so it can receive the keydown.
/// </summary>
private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
await HandleClose();
}
}
/// <summary>First 8 hex digits of the execution id, mirroring the UI's short-id convention.</summary>
private string ShortExecutionId()
{
if (ExecutionId is null)
{
return "—";
}
var n = ExecutionId.Value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private static string FormatTime(DateTime occurredAtUtc)
=> occurredAtUtc.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
/// <summary>
/// Bootstrap badge class for a row's status — green for the success
/// terminal state, red for failure/discard, amber for in-flight. Mirrors
/// the status-badge colouring used by the Audit Log results grid.
/// </summary>
private static string StatusBadgeClass(AuditStatus status) => status switch
{
AuditStatus.Delivered => "text-bg-success",
AuditStatus.Failed or AuditStatus.Discarded or AuditStatus.Parked => "text-bg-danger",
_ => "text-bg-warning",
};
}
@@ -0,0 +1,40 @@
/* Execution-Tree Node Detail Modal (Task 3).
The modal/backdrop base classes come from Bootstrap; this is hand-rolled
(no bootstrap.bundle.js modal API), so the backdrop needs an explicit
stacking context and the dialog a comfortable max width. The per-row detail
body styles travel with AuditEventDetail.razor.css. */
/* Bootstrap's .modal-backdrop sits below .modal by default; with the hand-
rolled approach we render both as siblings, so pin the dialog above it. */
.execution-detail-modal {
z-index: 1055;
}
/* The audit detail body can carry larger JSON/SQL payloads — a slightly wider
dialog than the Bootstrap default keeps those readable. Clamp to the
viewport so narrow windows still get the close button on screen. */
.execution-detail-modal .modal-dialog {
max-width: min(720px, 95vw);
}
/* Row-list buttons: a calm hover lift and a fixed-width status badge so the
Kind / Target columns align down the list. */
.execution-detail-row-list .list-group-item-action {
cursor: pointer;
}
.execution-detail-status {
flex-shrink: 0;
min-width: 5.5rem;
text-align: center;
}
/* Keep the back-to-list affordance quiet — it is navigation chrome, not a
primary action. */
.execution-detail-back-link {
text-decoration: none;
}
.execution-detail-back-link:hover {
text-decoration: underline;
}
@@ -0,0 +1,125 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
A custom recursive Blazor tree: the host hands in the FLAT ExecutionTreeNode
list the repository returns; this component assembles it into a tree (joining
ParentExecutionId → a parent's ExecutionId), then renders depth-first.
Recursion is expressed by the component rendering <ExecutionTree> for each
child subtree. To keep that recursion finite even on corrupt/cyclic input,
the assembled subtree is computed ONCE at the root (Depth == 0) and threaded
downward via the PreBuiltRoots parameter — child instances never re-run the
flat-list assembly, and the assembly itself tracks visited ExecutionIds so a
cycle is broken on first revisit. *@
@if (_rootsToRender.Count == 0)
{
return;
}
<ul class="execution-tree @(Depth == 0 ? "execution-tree--root" : "")"
data-test="execution-tree@(Depth == 0 ? "" : "-subtree")">
@foreach (var subtree in _rootsToRender)
{
var node = subtree.Node;
var isCurrent = node.ExecutionId == ArrivedFromExecutionId;
var isStub = node.RowCount == 0;
<li class="execution-tree-item" @key="node.ExecutionId">
<div class="execution-tree-node @(isCurrent ? "execution-tree-node--current" : "") @(isStub ? "execution-tree-node--stub" : "")"
data-test="tree-node-@node.ExecutionId">
@if (subtree.Children.Count > 0)
{
<button type="button"
class="execution-tree-toggle"
data-test="tree-toggle-@node.ExecutionId"
aria-expanded="@(IsExpanded(node.ExecutionId) ? "true" : "false")"
aria-label="@(IsExpanded(node.ExecutionId) ? "Collapse" : "Expand") child executions"
@onclick="() => ToggleExpand(node.ExecutionId)">
<span class="execution-tree-toggle-glyph" aria-hidden="true">
@(IsExpanded(node.ExecutionId) ? "" : "+")
</span>
</button>
}
else
{
<span class="execution-tree-toggle execution-tree-toggle--leaf" aria-hidden="true"></span>
}
<div class="execution-tree-body"
@ondblclick="() => OnNodeActivated.InvokeAsync(node.ExecutionId)">
<div class="execution-tree-headline">
<a class="execution-tree-link font-monospace"
data-test="tree-node-link-@node.ExecutionId"
href="@AuditLogUrl(node.ExecutionId)"
title="Open the Audit Log filtered to execution @node.ExecutionId">
@ShortId(node.ExecutionId)
</a>
@if (isCurrent)
{
<span class="badge text-bg-primary execution-tree-tag"
data-test="tree-current-tag-@node.ExecutionId">Arrived from</span>
}
@if (isStub)
{
<span class="badge text-bg-secondary execution-tree-tag"
data-test="stub-node-@node.ExecutionId">No audited actions</span>
}
else
{
<span class="execution-tree-rowcount text-muted small"
data-test="tree-rowcount-@node.ExecutionId">
@node.RowCount audit @(node.RowCount == 1 ? "row" : "rows")
</span>
}
</div>
@if (isStub)
{
<div class="execution-tree-meta text-muted small">
Execution with no audited actions — referenced as a parent, but it
emitted no audit rows of its own (or its rows have been purged).
</div>
}
else
{
<div class="execution-tree-meta small">
<span class="execution-tree-meta-item">
<span class="text-muted">Source</span>
@(node.SourceSiteId ?? "—")@(node.SourceInstanceId is null ? "" : " / " + node.SourceInstanceId)
</span>
@if (node.Channels.Count > 0)
{
<span class="execution-tree-meta-item">
<span class="text-muted">Channels</span>
@string.Join(", ", node.Channels)
</span>
}
@if (node.Statuses.Count > 0)
{
<span class="execution-tree-meta-item">
<span class="text-muted">Statuses</span>
@string.Join(", ", node.Statuses)
</span>
}
<span class="execution-tree-meta-item">
<span class="text-muted">Time span</span>
@FormatSpan(node.FirstOccurredAtUtc, node.LastOccurredAtUtc)
</span>
</div>
}
</div>
</div>
@if (subtree.Children.Count > 0 && IsExpanded(node.ExecutionId))
{
@* Recurse: each child subtree is already assembled, so the
nested instance renders directly from PreBuiltRoots and skips
the flat-list assembly entirely. *@
<ExecutionTree PreBuiltRoots="subtree.Children"
ArrivedFromExecutionId="ArrivedFromExecutionId"
OnNodeActivated="OnNodeActivated"
Depth="Depth + 1" />
}
</li>
}
</ul>
@@ -0,0 +1,276 @@
using System.Globalization;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Recursive Blazor tree component for the execution-chain view (Audit Log
/// ParentExecutionId feature, Task 10).
///
/// <para>
/// <b>Flat list → tree.</b> The repository / query service returns the chain as
/// a FLAT <see cref="ExecutionTreeNode"/> list (one per distinct execution). The
/// root instance (<see cref="Depth"/> == 0) assembles it once in
/// <see cref="OnParametersSet"/>: it groups by <see cref="ExecutionTreeNode.ExecutionId"/>,
/// links each node to its parent via <see cref="ExecutionTreeNode.ParentExecutionId"/>,
/// and identifies the roots (nodes whose parent is null or not present in the
/// list — a purged/ghost parent). Nested instances skip assembly: the parent
/// hands each child subtree down pre-built via <see cref="PreBuiltRoots"/>.
/// </para>
///
/// <para>
/// <b>Cycle safety.</b> The <c>ParentExecutionId</c> graph is acyclic by
/// construction, but the UI must not infinite-loop on corrupt data. Assembly
/// tracks visited <see cref="ExecutionTreeNode.ExecutionId"/> values while
/// walking children, so a node is attached to the tree at most once — a cycle
/// (A→B, B→A) is broken at the first revisit and every execution still renders
/// exactly once.
/// </para>
///
/// <para>
/// <b>Presentation.</b> Each node shows the short execution id (a link to
/// <c>/audit/log?executionId={id}</c>), row count, channels/statuses, source
/// site/instance, and time span. A stub node (<see cref="ExecutionTreeNode.RowCount"/>
/// == 0) is marked "No audited actions". The node the user arrived from
/// (<see cref="ArrivedFromExecutionId"/>) is highlighted. Nodes with children
/// are expandable; all nodes start expanded so the whole chain is visible.
/// </para>
/// </summary>
public partial class ExecutionTree
{
/// <summary>
/// One assembled subtree: a node plus its already-linked child subtrees.
/// Recursive — children are themselves <see cref="Subtree"/> values.
/// </summary>
/// <param name="Node">The execution this subtree is rooted at.</param>
/// <param name="Children">
/// Child subtrees, ordered by <c>(FirstOccurredAtUtc ?? DateTime.MaxValue,
/// ExecutionId)</c> — earliest first-occurrence time first, stub nodes
/// (null timestamp) last, with <c>ExecutionId</c> breaking ties.
/// </param>
public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList<Subtree> Children);
/// <summary>
/// The flat node list to assemble into a tree. Supplied on the ROOT
/// instance only (<see cref="Depth"/> == 0); nested instances receive
/// <see cref="PreBuiltRoots"/> instead.
/// </summary>
[Parameter] public IReadOnlyList<ExecutionTreeNode>? Nodes { get; set; }
/// <summary>
/// Pre-assembled child subtrees, threaded down from a parent
/// <see cref="ExecutionTree"/> so nested instances render without
/// re-running the flat-list assembly. Null / unused on the root instance.
/// </summary>
[Parameter] public IReadOnlyList<Subtree>? PreBuiltRoots { get; set; }
/// <summary>
/// The execution the user drilled in from — its node is visually
/// highlighted so the user keeps their bearings within the chain.
/// </summary>
[Parameter] public Guid ArrivedFromExecutionId { get; set; }
/// <summary>
/// Nesting depth. 0 on the root instance (which owns flat-list assembly);
/// each recursive child increments it. Used purely to pick the assembly
/// path and to tag the root <c>&lt;ul&gt;</c> for styling.
/// </summary>
[Parameter] public int Depth { get; set; }
/// <summary>
/// Raised when a node is double-clicked, carrying that node's
/// <see cref="ExecutionTreeNode.ExecutionId"/>. The same callback is
/// threaded unchanged into every recursive child instance, so a
/// double-click on a node at any depth invokes the root-supplied handler
/// (used to open the node detail modal).
/// </summary>
[Parameter] public EventCallback<Guid> OnNodeActivated { get; set; }
// The subtrees this instance renders: assembled from Nodes on the root,
// or taken straight from PreBuiltRoots on a nested instance.
private IReadOnlyList<Subtree> _rootsToRender = Array.Empty<Subtree>();
// The Nodes reference the current _rootsToRender was assembled from. Used
// to skip a redundant re-assembly when OnParametersSet fires for an
// unrelated parameter change (the flat list itself is unchanged).
private IReadOnlyList<ExecutionTreeNode>? _assembledFrom;
// Per-execution expand/collapse state. Absent => expanded (the default):
// the whole chain is shown on arrival so the user sees the full picture.
private readonly HashSet<Guid> _collapsed = new();
/// <inheritdoc />
protected override void OnParametersSet()
{
// Nested instance: the parent already assembled our subtrees.
if (Depth > 0)
{
_rootsToRender = PreBuiltRoots ?? Array.Empty<Subtree>();
return;
}
// Root instance: assemble the flat list into a tree. Re-assemble only
// when the Nodes reference itself changes — OnParametersSet also fires
// for unrelated parameter changes (e.g. ArrivedFromExecutionId), and
// re-running assembly then would needlessly rebuild an identical tree.
if (!ReferenceEquals(Nodes, _assembledFrom))
{
_assembledFrom = Nodes;
_rootsToRender = BuildForest(Nodes ?? Array.Empty<ExecutionTreeNode>());
}
}
/// <summary>
/// Assembles the flat <see cref="ExecutionTreeNode"/> list into a forest of
/// <see cref="Subtree"/> values. There is normally exactly one root (the
/// chain's topmost ancestor); the method returns a list to stay total if
/// the input ever contains disjoint fragments. A fully-cyclic feed has no
/// real root, so each remaining cyclic component is seeded with a fallback
/// root after the main pass — every execution in <paramref name="nodes"/>
/// is therefore placed in the forest exactly once.
/// </summary>
private static IReadOnlyList<Subtree> BuildForest(IReadOnlyList<ExecutionTreeNode> nodes)
{
if (nodes.Count == 0)
{
return Array.Empty<Subtree>();
}
// De-dupe defensively: the repository emits one node per execution, but
// a corrupt feed could repeat an id. First write wins.
var byId = new Dictionary<Guid, ExecutionTreeNode>();
foreach (var node in nodes)
{
byId.TryAdd(node.ExecutionId, node);
}
// Children grouped by parent id. A node whose parent is null or absent
// from the list (a purged/ghost parent) is a root.
var childrenByParent = new Dictionary<Guid, List<ExecutionTreeNode>>();
var roots = new List<ExecutionTreeNode>();
foreach (var node in byId.Values)
{
if (node.ParentExecutionId is { } parentId && byId.ContainsKey(parentId))
{
if (!childrenByParent.TryGetValue(parentId, out var bucket))
{
bucket = new List<ExecutionTreeNode>();
childrenByParent[parentId] = bucket;
}
bucket.Add(node);
}
else
{
roots.Add(node);
}
}
var visited = new HashSet<Guid>();
var forest = roots
.OrderBy(SortKey)
.Select(root => BuildSubtree(root, childrenByParent, visited))
.ToList();
// Cycle guard: if the input is fully cyclic every node has a present
// parent, so a cyclic component contributes no entry to `roots`. Any
// execution still missing from `visited` after the pass above belongs
// to such a component (a corrupt feed may contain several independent
// cycles, e.g. A↔B and C↔D). Seed the lowest-ordered unvisited id of
// each remaining component as an extra root and assemble it, looping
// until every node has been placed — so every execution renders.
while (visited.Count < byId.Count)
{
var fallbackRoot = byId.Values
.Where(n => !visited.Contains(n.ExecutionId))
.OrderBy(SortKey)
.First();
forest.Add(BuildSubtree(fallbackRoot, childrenByParent, visited));
}
return forest;
}
/// <summary>
/// Recursively builds one <see cref="Subtree"/>, tracking
/// <paramref name="visited"/> so a cyclic flat list cannot drive unbounded
/// recursion — a node already attached is never descended into again.
/// </summary>
private static Subtree BuildSubtree(
ExecutionTreeNode node,
IReadOnlyDictionary<Guid, List<ExecutionTreeNode>> childrenByParent,
HashSet<Guid> visited)
{
visited.Add(node.ExecutionId);
var children = new List<Subtree>();
if (childrenByParent.TryGetValue(node.ExecutionId, out var directChildren))
{
foreach (var child in directChildren.OrderBy(SortKey))
{
// Cycle / DAG guard: skip any execution already placed in the
// tree so each renders exactly once and recursion terminates.
if (visited.Contains(child.ExecutionId))
{
continue;
}
children.Add(BuildSubtree(child, childrenByParent, visited));
}
}
return new Subtree(node, children);
}
// Stable child ordering: earliest activity first; stub nodes (null
// timestamp) sort last; ExecutionId breaks ties so rendering is
// deterministic across requests.
private static (DateTime, Guid) SortKey(ExecutionTreeNode node)
=> (node.FirstOccurredAtUtc ?? DateTime.MaxValue, node.ExecutionId);
private bool IsExpanded(Guid executionId) => !_collapsed.Contains(executionId);
private void ToggleExpand(Guid executionId)
{
if (!_collapsed.Remove(executionId))
{
_collapsed.Add(executionId);
}
}
/// <summary>Audit Log deep link filtered to one execution's rows.</summary>
private static string AuditLogUrl(Guid executionId)
=> $"/audit/log?executionId={executionId}";
/// <summary>First 8 hex digits — the short-id presentation used across the Audit UI.</summary>
private static string ShortId(Guid value)
{
var n = value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
/// <summary>
/// Renders the [first, last] occurrence span. Both null on a stub node
/// (handled by the caller); a single-row execution shows one timestamp.
/// </summary>
private static string FormatSpan(DateTime? firstUtc, DateTime? lastUtc)
{
if (firstUtc is null && lastUtc is null)
{
return "—";
}
var first = firstUtc ?? lastUtc!.Value;
var last = lastUtc ?? firstUtc!.Value;
var firstText = Iso(first);
if (first == last)
{
return firstText;
}
return $"{firstText} → {Iso(last)}";
}
// Audit timestamps are UTC by system convention, so the value is formatted
// with a literal 'Z' suffix without re-tagging its DateTimeKind.
private static string Iso(DateTime utc)
=> utc.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,141 @@
/* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
Clean, corporate, internal-tool aesthetic — consistent with the Audit Log
grid / drilldown drawer. Bootstrap CSS variables drive every colour so the
tree tracks the active theme. No component framework, no JS for layout. */
.execution-tree {
list-style: none;
margin: 0;
padding: 0;
}
/* Nested lists indent and carry a vertical guide rule that ties children to
their parent — the classic file-tree connector, kept subtle. */
.execution-tree--root {
padding-left: 0;
}
.execution-tree .execution-tree {
margin-left: 0.75rem;
padding-left: 1rem;
border-left: 1px solid var(--bs-border-color);
}
.execution-tree-item {
position: relative;
}
/* The node card: a flex row of [toggle][body].
user-select: none — the body is double-clickable (opens the node detail
modal), so suppress the text selection a double-click would otherwise
leave behind. */
.execution-tree-node {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
margin: 0.25rem 0;
border: 1px solid var(--bs-border-color);
border-radius: 0.375rem;
background-color: var(--bs-body-bg);
user-select: none;
}
/* The execution the user drilled in from — a left accent rule + tinted
background so it stands out without shouting. */
.execution-tree-node--current {
border-color: var(--bs-primary-border-subtle);
background-color: var(--bs-primary-bg-subtle);
box-shadow: inset 3px 0 0 0 var(--bs-primary);
}
/* Stub node — an execution with no audited actions. Muted + dashed border so
it reads as a placeholder rather than a real audited execution. */
.execution-tree-node--stub {
border-style: dashed;
background-color: var(--bs-tertiary-bg);
}
/* Expand / collapse control. A small square that mirrors the table-light
header tone used elsewhere on the Audit pages. */
.execution-tree-toggle {
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
margin-top: 0.0625rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--bs-border-color);
border-radius: 0.25rem;
background-color: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
line-height: 1;
cursor: pointer;
}
.execution-tree-toggle:hover {
background-color: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
.execution-tree-toggle--leaf {
border-color: transparent;
background-color: transparent;
cursor: default;
}
.execution-tree-toggle-glyph {
font-size: 0.875rem;
font-weight: 600;
}
.execution-tree-body {
flex: 1 1 auto;
min-width: 0;
}
/* Headline row: short id link, tags, row count. */
.execution-tree-headline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.execution-tree-link {
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
}
.execution-tree-link:hover {
text-decoration: underline;
}
.execution-tree-tag {
font-weight: 500;
font-size: 0.6875rem;
}
.execution-tree-rowcount {
margin-left: auto;
}
/* Meta row: source / channels / statuses / time span, pipe-separated visually
via spacing rather than literal separators. */
.execution-tree-meta {
margin-top: 0.25rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
color: var(--bs-body-color);
}
.execution-tree-meta-item .text-muted {
margin-right: 0.25rem;
text-transform: uppercase;
font-size: 0.6875rem;
letter-spacing: 0.02em;
}
@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components;
/// <summary>
/// Converts <c>&lt;input type="datetime-local"&gt;</c> values — which are always
/// expressed in the user's <i>browser-local</i> time zone — into UTC
/// <see cref="DateTimeOffset"/>s for querying.
/// <para>
/// CLAUDE.md mandates UTC throughout the system, but a <c>datetime-local</c>
/// value carries no offset, so it must be <i>converted</i> to UTC, not relabelled
/// as UTC. Relabelling (the CentralUI-008 bug) shifts every query window by the
/// user's offset for any non-UTC browser.
/// </para>
/// </summary>
public static class BrowserTime
{
/// <summary>
/// Converts a browser-local <paramref name="localValue"/> to UTC using the
/// browser's <c>Date.getTimezoneOffset()</c> result.
/// </summary>
/// <param name="localValue">
/// The wall-clock value from a <c>datetime-local</c> input, or <c>null</c>.
/// </param>
/// <param name="browserUtcOffsetMinutes">
/// The value of JavaScript <c>new Date().getTimezoneOffset()</c>: the number
/// of minutes that, <b>added</b> to local time, yields UTC. It is positive
/// for time zones behind UTC (e.g. +300 for UTC-5) and negative for zones
/// ahead (e.g. -120 for UTC+2).
/// </param>
/// <returns>The equivalent instant in UTC, or <c>null</c> when the input is null.</returns>
public static DateTimeOffset? LocalInputToUtc(DateTime? localValue, int browserUtcOffsetMinutes)
{
if (localValue is not { } local)
return null;
// getTimezoneOffset() is defined as (UTC - local) in minutes, so
// UTC = local + offset.
var utc = DateTime.SpecifyKind(local, DateTimeKind.Unspecified)
.AddMinutes(browserUtcOffsetMinutes);
return new DateTimeOffset(utc, TimeSpan.Zero);
}
}
@@ -0,0 +1,274 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Forms
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
<div class="opcua-endpoint-editor">
<h6 class="text-muted border-bottom pb-1">@Title</h6>
@if (IsLegacy)
{
<div class="alert alert-warning py-1 small mb-2">
This connection was migrated from a legacy format.
Review the settings and Save to update.
</div>
}
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Endpoint URL</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.EndpointUrl"
placeholder="opc.tcp://host:4840" />
@RenderFieldError("EndpointUrl")
</div>
<div class="col-md-3">
<label class="form-label small">Security Mode</label>
<select class="form-select form-select-sm" @bind="Config.SecurityMode">
<option value="@OpcUaSecurityMode.None">None</option>
<option value="@OpcUaSecurityMode.Sign">Sign</option>
<option value="@OpcUaSecurityMode.SignAndEncrypt">Sign &amp; Encrypt</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-autoaccept")"
@bind="Config.AutoAcceptUntrustedCerts" />
<label class="form-check-label small"
for="@($"{IdPrefix}-autoaccept")">Auto-accept certs</label>
</div>
</div>
</div>
<div class="text-muted small mt-2 mb-1">Authentication</div>
@if (Config.UserIdentity is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableAuthentication">Enable Authentication</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Token type</label>
<select class="form-select form-select-sm" @bind="Config.UserIdentity.TokenType">
<option value="@OpcUaUserTokenType.Anonymous">Anonymous</option>
<option value="@OpcUaUserTokenType.UsernamePassword">Username / Password</option>
<option value="@OpcUaUserTokenType.X509Certificate">X.509 Certificate</option>
</select>
</div>
@if (Config.UserIdentity.TokenType == OpcUaUserTokenType.UsernamePassword)
{
<div class="col-md-3">
<label class="form-label small">Username</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.UserIdentity.Username" />
@RenderFieldError("UserIdentity.Username")
</div>
<div class="col-md-3">
<label class="form-label small">Password</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.UserIdentity.Password" />
</div>
}
else if (Config.UserIdentity.TokenType == OpcUaUserTokenType.X509Certificate)
{
<div class="col-md-4">
<label class="form-label small">Certificate path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.UserIdentity.CertificatePath"
placeholder="/etc/scadabridge/pki/client.pfx" />
@RenderFieldError("UserIdentity.CertificatePath")
</div>
<div class="col-md-3">
<label class="form-label small">Certificate password</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.UserIdentity.CertificatePassword" />
</div>
}
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.UserIdentity = null">
Remove Authentication
</button>
</div>
</div>
}
<div class="text-muted small mt-2 mb-1">Timing</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Session timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SessionTimeoutMs" min="1" />
@RenderFieldError("SessionTimeoutMs")
</div>
<div class="col-md-3">
<label class="form-label small">Operation timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.OperationTimeoutMs" min="1" />
@RenderFieldError("OperationTimeoutMs")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Subscription</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Publishing interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.PublishingIntervalMs" min="1" />
@RenderFieldError("PublishingIntervalMs")
</div>
<div class="col-md-3">
<label class="form-label small">Sampling interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SamplingIntervalMs" min="1" />
@RenderFieldError("SamplingIntervalMs")
</div>
<div class="col-md-2">
<label class="form-label small">Queue size</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.QueueSize" min="1" />
@RenderFieldError("QueueSize")
</div>
<div class="col-md-2">
<label class="form-label small">Keep-alive count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.KeepAliveCount" min="1" />
@RenderFieldError("KeepAliveCount")
</div>
<div class="col-md-2">
<label class="form-label small">Lifetime count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.LifetimeCount" min="1" />
@RenderFieldError("LifetimeCount")
</div>
<div class="col-md-3">
<label class="form-label small">Max notifications / publish</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.MaxNotificationsPerPublish" min="1" />
@RenderFieldError("MaxNotificationsPerPublish")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Advanced subscription</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Subscription display name</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.SubscriptionDisplayName" />
@RenderFieldError("SubscriptionDisplayName")
</div>
<div class="col-md-2">
<label class="form-label small">Subscription priority</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SubscriptionPriority" min="0" max="255" />
</div>
<div class="col-md-3">
<label class="form-label small">Timestamps to return</label>
<select class="form-select form-select-sm" @bind="Config.TimestampsToReturn">
<option value="@OpcUaTimestampsToReturn.Source">Source</option>
<option value="@OpcUaTimestampsToReturn.Server">Server</option>
<option value="@OpcUaTimestampsToReturn.Both">Both</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-discardoldest")"
@bind="Config.DiscardOldest" />
<label class="form-check-label small"
for="@($"{IdPrefix}-discardoldest")">Discard oldest</label>
</div>
</div>
</div>
<div class="text-muted small mt-2 mb-1">Deadband filter</div>
@if (Config.Deadband is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableDeadband">Enable Deadband</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" @bind="Config.Deadband.Type">
<option value="@OpcUaDeadbandType.Absolute">Absolute</option>
<option value="@OpcUaDeadbandType.Percent">Percent</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Value</label>
<input type="number" step="0.01" class="form-control form-control-sm"
@bind="Config.Deadband.Value" min="0" />
@RenderFieldError("Deadband.Value")
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.Deadband = null">
Remove Deadband
</button>
</div>
</div>
}
<div class="text-muted small mt-2 mb-1">Heartbeat</div>
@if (Config.Heartbeat is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableHeartbeat">Enable Heartbeat</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Tag path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.Heartbeat.TagPath"
placeholder="Sensors.Heartbeat" />
@RenderFieldError("Heartbeat.TagPath")
</div>
<div class="col-md-3">
<label class="form-label small">Max silence (s)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.Heartbeat.MaxSilenceSeconds" min="1" />
@RenderFieldError("Heartbeat.MaxSilenceSeconds")
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.Heartbeat = null">
Remove Heartbeat
</button>
</div>
</div>
}
</div>
@code {
[Parameter, EditorRequired] public OpcUaEndpointConfig Config { get; set; } = default!;
[Parameter] public string Title { get; set; } = "Endpoint";
[Parameter] public string IdPrefix { get; set; } = "endpoint";
[Parameter] public bool IsLegacy { get; set; }
[Parameter] public ValidationResult? Errors { get; set; }
private void EnableHeartbeat() =>
Config.Heartbeat = new OpcUaHeartbeatConfig();
private void EnableAuthentication() =>
Config.UserIdentity = new OpcUaUserIdentityConfig();
private void EnableDeadband() =>
Config.Deadband = new OpcUaDeadbandConfig();
private RenderFragment? RenderFieldError(string field)
{
var match = Errors?.Errors.FirstOrDefault(e =>
e.EntityName != null
&& (e.EntityName == field || e.EntityName.EndsWith("." + field)));
return match is null
? null
: @<div class="text-danger small">@match.Message</div>;
}
}
@@ -0,0 +1,59 @@
@*
Audit Log (#23) M7 Bundle E (T13) — three Health-dashboard KPI tiles for the
Audit channel: Volume / Error rate / Backlog. Renders Bootstrap card tiles in
a single row, each acting as a navigation link to a pre-filtered Audit Log
view. The component is purely presentational — the parent page owns the
refresh loop and passes the latest snapshot via the Snapshot parameter.
*@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health
@inject NavigationManager Navigation
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Audit</h6>
<a class="small" href="/audit/log">View details &rarr;</a>
</div>
<div class="row g-3 mb-3">
@* ── Volume tile ───────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 audit-kpi-tile"
data-test="audit-kpi-volume"
@onclick="NavigateToVolume">
<div class="card-body text-center">
<h3 class="mb-0">@VolumeDisplay</h3>
<small class="text-muted">Audit volume (last hour)</small>
</div>
</button>
</div>
@* ── Error rate tile ───────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 audit-kpi-tile @ErrorRateBorderClass"
data-test="audit-kpi-error-rate"
@onclick="NavigateToErrors">
<div class="card-body text-center">
<h3 class="mb-0 @ErrorRateTextClass">@ErrorRateDisplay</h3>
<small class="text-muted">Audit error rate (last hour)</small>
</div>
</button>
</div>
@* ── Backlog tile ──────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 audit-kpi-tile @BacklogBorderClass"
data-test="audit-kpi-backlog"
@onclick="NavigateToBacklog">
<div class="card-body text-center">
<h3 class="mb-0 @BacklogTextClass">@BacklogDisplay</h3>
<small class="text-muted">Audit backlog (sites pending)</small>
</div>
</button>
</div>
</div>
@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage))
{
<div class="text-muted small mb-3">Audit KPIs unavailable: @ErrorMessage</div>
}
@@ -0,0 +1,157 @@
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
/// <summary>
/// Audit Log (#23) M7 Bundle E (T13) code-behind for <see cref="AuditKpiTiles"/>.
/// Renders three KPI tiles — volume, error rate, backlog — from a
/// <see cref="AuditLogKpiSnapshot"/> the parent page supplies. Tiles act as
/// drill-in links: clicking navigates to <c>/audit/log</c> with the relevant
/// query-string filter pre-applied (Bundle D already parses these params).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why purely presentational.</b> The Health dashboard already owns a 10s
/// auto-refresh loop and an "as-of" timestamp display; pushing those concerns
/// into the tile component would either duplicate them (one timer per tile) or
/// awkwardly couple back to the page. The parent passes a fresh
/// <see cref="AuditLogKpiSnapshot"/> every refresh and the tile component
/// re-renders.
/// </para>
/// <para>
/// <b>Error rate division.</b> When <c>TotalEventsLastHour == 0</c> we render
/// "0%" rather than "—" — the snapshot itself is available, the system just had
/// no audit traffic to evaluate. This avoids a divide-by-zero AND keeps the
/// "0% errors" reading semantically true. The em dash is reserved for
/// <see cref="IsAvailable"/> = <c>false</c>, which represents a failed snapshot
/// query (different signal from "quiet hour").
/// </para>
/// </remarks>
public partial class AuditKpiTiles
{
/// <summary>
/// Latest KPI snapshot. <c>null</c> means the parent has not loaded it yet
/// or the load failed — the tiles render em dashes in that case.
/// </summary>
[Parameter] public AuditLogKpiSnapshot? Snapshot { get; set; }
/// <summary>
/// True when <see cref="Snapshot"/> is a successful query result. False
/// when the parent's refresh threw and the displayed values should be
/// rendered as em dashes with an error explanation underneath.
/// </summary>
[Parameter] public bool IsAvailable { get; set; }
/// <summary>
/// Optional error message to render underneath the tiles when
/// <see cref="IsAvailable"/> is false. Mirrors how the Notification Outbox
/// section on the Health dashboard surfaces transient KPI failures.
/// </summary>
[Parameter] public string? ErrorMessage { get; set; }
// ── Volume tile ─────────────────────────────────────────────────────────
private string VolumeDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.TotalEventsLastHour.ToString("N0")
: "—";
private void NavigateToVolume()
{
// Volume is "all audit rows in the last hour" — no status filter; the
// page's existing instance-search seam is enough for drill-in. We rely
// on the page's default render which omits a time-range constraint and
// shows the newest rows first.
Navigation.NavigateTo("/audit/log");
}
// ── Error rate tile ─────────────────────────────────────────────────────
/// <summary>
/// Percentage of error rows (Failed/Parked/Discarded) over the trailing
/// hour. Returns 0 when the snapshot is unavailable OR when total events
/// is zero (rather than throwing). The display layer renders "—" for the
/// unavailable case and "0%" for the zero-events case.
/// </summary>
internal double ErrorRatePercent
{
get
{
if (!IsAvailable || Snapshot is null || Snapshot.TotalEventsLastHour <= 0)
{
return 0;
}
return 100.0 * Snapshot.ErrorEventsLastHour / Snapshot.TotalEventsLastHour;
}
}
private string ErrorRateDisplay
{
get
{
if (!IsAvailable || Snapshot is null)
{
return "—";
}
// Format to one decimal so a 1-error-in-2000 rate doesn't round to 0%.
return $"{ErrorRatePercent:0.0}%";
}
}
// Border + text colour bracket the tile visually: any nonzero error rate
// gets a warning border; anything above 10% bumps it to danger. The
// thresholds match the Notification Outbox tile pattern (border-warning
// when Stuck > 0, border-danger when Parked > 0).
private string ErrorRateBorderClass =>
!IsAvailable || Snapshot is null || Snapshot.ErrorEventsLastHour == 0
? string.Empty
: (ErrorRatePercent >= 10 ? "border-danger" : "border-warning");
private string ErrorRateTextClass =>
!IsAvailable || Snapshot is null || Snapshot.ErrorEventsLastHour == 0
? string.Empty
: (ErrorRatePercent >= 10 ? "text-danger" : "text-warning");
private void NavigateToErrors()
{
// Drill in pre-filtered to Failed — the most common error class.
// (The Audit Log page also accepts ?status=Parked / =Discarded for
// operators who want to see those specifically; the tile picks Failed
// as the primary surface since it's the only synchronous-failure
// status. Parked + Discarded both still appear in the unfiltered grid.)
Navigation.NavigateTo("/audit/log?status=Failed");
}
// ── Backlog tile ────────────────────────────────────────────────────────
private string BacklogDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.BacklogTotal.ToString("N0")
: "—";
// Backlog above zero is itself a signal — sites should normally drain to
// empty. We render warning when there's a backlog at all; a hard danger
// threshold could be added later if ops want it but the on-call playbook
// for "backlog > 0" is the same as "backlog > 1000": check why the site
// isn't draining.
private string BacklogBorderClass =>
IsAvailable && Snapshot is not null && Snapshot.BacklogTotal > 0
? "border-warning"
: string.Empty;
private string BacklogTextClass =>
IsAvailable && Snapshot is not null && Snapshot.BacklogTotal > 0
? "text-warning"
: string.Empty;
private void NavigateToBacklog()
{
// The audit-log page itself doesn't carry a per-site backlog grid —
// the Health dashboard already shows that per-site card. The natural
// drill-in for "the system has a backlog" is the unfiltered Audit Log
// page sorted by newest, so an operator can see the most recent rows
// and judge whether the queue is moving.
Navigation.NavigateTo("/audit/log");
}
}
@@ -0,0 +1,60 @@
@*
Site Call Audit (#22) Task 7 — three Health-dashboard KPI tiles for the
Site Call channel: Buffered / Parked / Stuck. Renders Bootstrap card tiles
in a single row, each acting as a navigation link to a pre-filtered Site
Calls report view. The component is purely presentational — the parent page
owns the refresh loop and passes the latest snapshot via the Snapshot
parameter. Mirrors AuditKpiTiles and the Notification Outbox KPI section.
*@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health
@inject NavigationManager Navigation
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Site Calls</h6>
<a class="small" href="/site-calls/report">View details &rarr;</a>
</div>
<div class="row g-3 mb-3">
@* ── Buffered tile ─────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile"
data-test="site-call-kpi-buffered"
@onclick="NavigateToBuffered">
<div class="card-body text-center">
<h3 class="mb-0">@BufferedDisplay</h3>
<small class="text-muted">Buffered</small>
</div>
</button>
</div>
@* ── Stuck tile ────────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @StuckBorderClass"
data-test="site-call-kpi-stuck"
@onclick="NavigateToStuck">
<div class="card-body text-center">
<h3 class="mb-0 @StuckTextClass">@StuckDisplay</h3>
<small class="text-muted">Stuck</small>
</div>
</button>
</div>
@* ── Parked tile ───────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @ParkedBorderClass"
data-test="site-call-kpi-parked"
@onclick="NavigateToParked">
<div class="card-body text-center">
<h3 class="mb-0 @ParkedTextClass">@ParkedDisplay</h3>
<small class="text-muted">Parked</small>
</div>
</button>
</div>
</div>
@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage))
{
<div class="text-muted small mb-3">Site Call KPIs unavailable: @ErrorMessage</div>
}
@@ -0,0 +1,130 @@
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
/// <summary>
/// Site Call Audit (#22) Task 7 code-behind for <see cref="SiteCallKpiTiles"/>.
/// Renders three KPI tiles — Buffered, Stuck, Parked — from a
/// <see cref="SiteCallKpiResponse"/> the parent Health dashboard supplies.
/// Tiles act as drill-in links: clicking navigates to <c>/site-calls/report</c>
/// with the relevant query-string filter pre-applied. Mirrors
/// <see cref="AuditKpiTiles"/> and the Notification Outbox KPI section on the
/// Health dashboard.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why purely presentational.</b> The Health dashboard already owns a 10s
/// auto-refresh loop; pushing that into the tile component would either
/// duplicate it (one timer per tile) or awkwardly couple back to the page. The
/// parent passes a fresh <see cref="SiteCallKpiResponse"/> every refresh and the
/// tile component re-renders. This is the same contract <see cref="AuditKpiTiles"/>
/// follows.
/// </para>
/// <para>
/// <b>Snapshot shape.</b> Unlike <see cref="AuditKpiTiles"/> — which takes a
/// dedicated <c>AuditLogKpiSnapshot</c> type — Site Call KPIs travel in the
/// <see cref="SiteCallKpiResponse"/> message itself (it carries the KPI fields
/// directly), so that record doubles as the snapshot here. <see cref="IsAvailable"/>
/// is a separate flag rather than the record's own <c>Success</c> so the parent
/// can also surface a transport failure (an Ask that threw) as unavailable.
/// </para>
/// <para>
/// <b>Threshold borders.</b> Mirrors the Notification Outbox tile pattern: the
/// Parked tile gets a danger border when <c>ParkedCount &gt; 0</c>; the Stuck
/// tile gets a warning border when <c>StuckCount &gt; 0</c>. Buffered is a plain
/// count tile with no threshold colour — a non-zero buffer is normal operation.
/// </para>
/// </remarks>
public partial class SiteCallKpiTiles
{
/// <summary>
/// Latest KPI snapshot. <c>null</c> means the parent has not loaded it yet
/// or the load failed — the tiles render em dashes in that case.
/// </summary>
[Parameter] public SiteCallKpiResponse? Snapshot { get; set; }
/// <summary>
/// True when <see cref="Snapshot"/> is a successful query result. False when
/// the parent's refresh threw, or the response itself reported a fault, and
/// the displayed values should be rendered as em dashes with an error
/// explanation underneath.
/// </summary>
[Parameter] public bool IsAvailable { get; set; }
/// <summary>
/// Optional error message to render underneath the tiles when
/// <see cref="IsAvailable"/> is false. Mirrors how the Notification Outbox
/// section on the Health dashboard surfaces transient KPI failures.
/// </summary>
[Parameter] public string? ErrorMessage { get; set; }
// ── Buffered tile ───────────────────────────────────────────────────────
private string BufferedDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.BufferedCount.ToString("N0")
: "—";
private void NavigateToBuffered()
{
// Buffered is "everything still in flight" — no single status maps to
// it, so the natural drill-in is the unfiltered Site Calls report sorted
// by newest, mirroring how the Audit volume/backlog tiles drop the
// operator on the unfiltered Audit Log grid.
Navigation.NavigateTo("/site-calls/report");
}
// ── Stuck tile ──────────────────────────────────────────────────────────
private string StuckDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.StuckCount.ToString("N0")
: "—";
// Stuck above zero is a warning signal — cached calls that have been
// Pending/Retrying past the stuck-age threshold. Matches the Notification
// Outbox Stuck tile (border-warning when StuckCount > 0).
private string StuckBorderClass =>
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
? "border-warning"
: string.Empty;
private string StuckTextClass =>
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
? "text-warning"
: string.Empty;
private void NavigateToStuck()
{
// Drill in with the report's "stuck only" filter pre-applied.
Navigation.NavigateTo("/site-calls/report?stuck=true");
}
// ── Parked tile ─────────────────────────────────────────────────────────
private string ParkedDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.ParkedCount.ToString("N0")
: "—";
// Parked above zero is a danger signal — cached calls that exhausted retries
// and need an operator Retry/Discard. Matches the Notification Outbox Parked
// tile (border-danger when ParkedCount > 0).
private string ParkedBorderClass =>
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
? "border-danger"
: string.Empty;
private string ParkedTextClass =>
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
? "text-danger"
: string.Empty;
private void NavigateToParked()
{
// Drill in pre-filtered to Parked — the report's Status filter accepts
// ?status=Parked and Parked rows carry the Retry/Discard relay actions.
Navigation.NavigateTo("/site-calls/report?status=Parked");
}
}
@@ -0,0 +1,5 @@
@inherits LayoutComponentBase
@* Minimal layout for the login page: no nav sidebar, no session-expiry
watchdog, no dialog host. The page renders its own centred card. *@
@Body
@@ -0,0 +1,29 @@
@inherits LayoutComponentBase
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
@* Hamburger toggle: visible only on viewports <lg.
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-collapse"
aria-controls="sidebar-collapse"
aria-expanded="false"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="collapse d-lg-block" id="sidebar-collapse">
<NavMenu />
</div>
<main class="flex-grow-1 p-3">
@Body
</main>
</div>
@* Global host for IDialogService. One instance per layout renders all confirm/prompt
dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@
<DialogHost />
<SessionExpiry />
@@ -0,0 +1,323 @@
@using System.Linq
@using ZB.MOM.WW.ScadaBridge.Security
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop
@implements IDisposable
@inject NavigationManager Navigation
@inject IJSRuntime JS
<nav class="sidebar d-flex flex-column">
<div class="brand"><span class="mark">&#9646;</span> ScadaBridge</div>
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
<ul class="nav flex-column">
<li class="nav-item">
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
</li>
<AuthorizeView>
<Authorized>
@* Admin section — Admin role only *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="adminContext">
<NavSection Title="Admin"
Expanded="@_expanded.Contains("admin")"
OnToggle="@(() => ToggleAsync("admin"))">
<li class="nav-item">
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
</li>
@* Import Bundle requires Admin only — Design role is not sufficient.
Export Bundle lives in the Design section (RequireDesign). *@
<li class="nav-item">
<NavLink class="nav-link" href="/design/transport/import">Import Bundle</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
@* Design section — Design role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="designContext">
<NavSection Title="Design"
Expanded="@_expanded.Contains("design")"
OnToggle="@(() => ToggleAsync("design"))">
<li class="nav-item">
<NavLink class="nav-link" href="/design/templates">Templates</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/design/connections">Connections</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
@* Deployment section — Deployment role *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="deploymentContext">
<NavSection Title="Deployment"
Expanded="@_expanded.Contains("deployment")"
OnToggle="@(() => ToggleAsync("deployment"))">
<li class="nav-item">
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
@* Notifications — mixed-role section; each item gated by its own policy.
The section is ungated: every authenticated user holds at least one of
Admin/Design/Deployment, so it always has a visible child. *@
<NavSection Title="Notifications"
Expanded="@_expanded.Contains("notifications")"
OnToggle="@(() => ToggleAsync("notifications"))">
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="notifAdminContext">
<li class="nav-item">
<NavLink class="nav-link" href="/notifications/smtp">SMTP Configuration</NavLink>
</li>
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
<Authorized Context="notifDesignContext">
<li class="nav-item">
<NavLink class="nav-link" href="/notifications/lists">Notification Lists</NavLink>
</li>
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="notifDeploymentContext">
<li class="nav-item">
<NavLink class="nav-link" href="/notifications/report">Notification Report</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/notifications/kpis">Notification KPIs</NavLink>
</li>
</Authorized>
</AuthorizeView>
</NavSection>
@* Site Calls — Site Call Audit (#22). Deployment-role only,
matching the Notification Report page's gate; the whole
section sits inside the policy block so a non-Deployment
user does not see the heading. *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="siteCallsContext">
<NavSection Title="Site Calls"
Expanded="@_expanded.Contains("sitecalls")"
OnToggle="@(() => ToggleAsync("sitecalls"))">
<li class="nav-item">
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
@* Monitoring — Health Dashboard is all-roles; Event Logs and
Parked Messages are Deployment-role only (Component-CentralUI).
The section is ungated because Health Dashboard is always
a visible child. *@
<NavSection Title="Monitoring"
Expanded="@_expanded.Contains("monitoring")"
OnToggle="@(() => ToggleAsync("monitoring"))">
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
</li>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="monitoringContext">
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
</li>
</Authorized>
</AuthorizeView>
</NavSection>
@* Audit — gated on the OperationalAudit policy (#23 M7-T15
/ Bundle G). Hosts the Audit Log page (#23 M7) and the
Configuration Audit Log (IAuditService config-change
viewer). The whole section sits inside the policy block:
a non-audit user does not even see the heading.
OperationalAudit is satisfied by the Admin, Audit, and
AuditReadOnly roles. *@
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
<Authorized Context="auditContext">
<NavSection Title="Audit"
Expanded="@_expanded.Contains("audit")"
OnToggle="@(() => ToggleAsync("audit"))">
<li class="nav-item">
<NavLink class="nav-link" href="/audit/log">Audit Log</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/audit/configuration">Configuration Audit Log</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
</Authorized>
</AuthorizeView>
</ul>
</div>
<AuthorizeView>
<Authorized>
<div class="border-top px-3 py-2">
<div class="d-flex justify-content-between align-items-center">
@* CentralUI-024: claim type resolved via JwtTokenService. *@
<span class="text-body-secondary small">@context.User.GetDisplayName()</span>
<form method="post" action="/auth/logout" data-enhance="false">
@* CentralUI-017: logout is a state-changing POST and is
CSRF-protected — the antiforgery token is required. *@
<AntiforgeryToken />
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
</form>
</div>
</div>
</Authorized>
</AuthorizeView>
</nav>
@code {
// Expanded-section state persists in the "scadabridge_nav" cookie, written
// by navState.set / read by navState.get (wwwroot/js/nav-state.js) — a
// comma-separated list of section ids.
// Every collapsible section id. Also the allow-list for parsing the cookie.
private static readonly string[] SectionIds =
{ "admin", "design", "deployment", "notifications", "sitecalls", "monitoring", "audit" };
// The currently-expanded sections. Populated from the cookie on first
// render; mutated by ToggleAsync and by navigating into a section.
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
protected override void OnInitialized()
{
Navigation.LocationChanged += OnLocationChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
// Hydrate from the cookie. Until this completes the sidebar paints
// collapsed (the "collapsed by default" state) — matching how TreeView
// hydrates its expand state in OnAfterRenderAsync(firstRender).
string saved;
try
{
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
}
catch (JSDisconnectedException)
{
return;
}
foreach (var id in saved.Split(
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (Array.IndexOf(SectionIds, id) >= 0)
{
_expanded.Add(id);
}
}
// The section of the page we loaded on is always expanded.
if (EnsureCurrentSectionExpanded())
{
await PersistAsync();
}
StateHasChanged();
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
// Navigating into a collapsed section expands it (and remembers it).
if (EnsureCurrentSectionExpanded())
{
_ = PersistAsync();
_ = InvokeAsync(StateHasChanged);
}
}
private async Task ToggleAsync(string id)
{
if (!_expanded.Remove(id))
{
_expanded.Add(id);
}
await PersistAsync();
}
// Adds the current page's section to _expanded; returns true if it changed.
private bool EnsureCurrentSectionExpanded()
{
var section = CurrentSection();
return section is not null && _expanded.Add(section);
}
// Maps the current URL's first path segment to a section id, or null for
// sectionless pages (Dashboard, Login).
private string? CurrentSection()
{
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
var firstSegment = relative.Split('?', '#')[0]
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
return firstSegment switch
{
"admin" => "admin",
"design" => "design",
"deployment" => "deployment",
"notifications" => "notifications",
"site-calls" => "sitecalls",
"monitoring" => "monitoring",
"audit" => "audit",
_ => null,
};
}
private async Task PersistAsync()
{
try
{
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
}
catch (JSDisconnectedException)
{
// The circuit is gone — nothing to persist to.
}
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
}
}
@@ -0,0 +1,35 @@
@* A collapsible sidebar nav section: an uppercase-eyebrow header button that
toggles the visibility of its child nav items. The header <li> and the item
<li>s (ChildContent) render as siblings inside NavMenu's <ul>. *@
<li class="nav-item">
<button type="button"
class="nav-section-toggle"
@onclick="OnToggle"
aria-expanded="@(Expanded ? "true" : "false")">
<i class="bi @(Expanded ? "bi-chevron-down" : "bi-chevron-right")" aria-hidden="true"></i>
<span>@Title</span>
</button>
</li>
@if (Expanded)
{
@ChildContent
}
@code {
/// <summary>Section label shown in the header (e.g. "Deployment").</summary>
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
/// <summary>Whether the section is expanded — its items rendered.</summary>
[Parameter]
public bool Expanded { get; set; }
/// <summary>Raised when the header button is clicked.</summary>
[Parameter]
public EventCallback OnToggle { get; set; }
/// <summary>The section's nav items, rendered only while expanded.</summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,247 @@
@page "/admin/api-keys/create"
@page "/admin/api-keys/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2"
aria-label="Back to API Keys">&larr; Back</a>
<span class="text-muted me-2">·</span>
<h4 class="mb-0">
@if (_saved)
{
@:API Key Created
}
else if (IsEditMode)
{
@:Edit API Key
}
else
{
@:Add API Key
}
</h4>
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log
pre-filtered to this API key's inbound calls. Inbound audit rows record
the key Name as Actor and live on the ApiInbound channel. *@
@if (IsEditMode && !string.IsNullOrWhiteSpace(_formName))
{
<a class="btn btn-outline-secondary btn-sm ms-auto"
href="/audit/log?actor=@Uri.EscapeDataString(_formName)&channel=ApiInbound"
data-test="audit-link">
Recent audit activity
</a>
}
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_saved && _newlyCreatedKeyValue != null)
{
<div class="alert alert-success">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
</div>
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
@if (IsEditMode)
{
<div class="mb-2">
<label class="form-label small">API Method Access</label>
@if (_allMethods.Count == 0)
{
<div class="form-text">
No API methods configured.
<a href="/design/external-systems">Create one</a> to grant access.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var method in _allMethods.OrderBy(m => m.Name))
{
var checkboxId = $"method-access-{method.Id}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedMethodIds.Contains(method.Id)"
@onchange="e => ToggleMethod(method.Id, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">@method.Name</label>
</div>
}
</div>
<div class="form-text">
Callers using this key can invoke any checked method.
</div>
}
</div>
}
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => _editingKey != null;
private ApiKey? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _errorMessage;
private string? _newlyCreatedKeyValue;
private bool _loading = true;
private bool _saved;
private List<ApiMethod> _allMethods = new();
private HashSet<int> _initialMethodIds = new();
private HashSet<int> _selectedMethodIds = new();
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
try
{
if (Id.HasValue)
{
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
if (_editingKey == null)
{
_errorMessage = $"API key with ID {Id.Value} not found.";
}
else
{
_formName = _editingKey.Name;
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
_initialMethodIds = _allMethods
.Where(m => ParseApprovedKeyIds(m.ApprovedApiKeyIds).Contains(_editingKey.Id))
.Select(m => m.Id)
.ToHashSet();
_selectedMethodIds = new HashSet<int>(_initialMethodIds);
}
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load API key: {ex.Message}";
}
_loading = false;
}
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
var changedIds = _selectedMethodIds
.Except(_initialMethodIds)
.Concat(_initialMethodIds.Except(_selectedMethodIds))
.ToHashSet();
foreach (var method in _allMethods.Where(m => changedIds.Contains(m.Id)))
{
var ids = ParseApprovedKeyIds(method.ApprovedApiKeyIds);
if (_selectedMethodIds.Contains(method.Id)) ids.Add(_editingKey.Id);
else ids.Remove(_editingKey.Id);
method.ApprovedApiKeyIds = ids.Count == 0
? null
: string.Join(",", ids.OrderBy(x => x));
await InboundApiRepository.UpdateApiMethodAsync(method);
}
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/api-keys");
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
await InboundApiRepository.AddApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_newlyCreatedKeyValue = keyValue;
_saved = true;
}
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
private void ToggleMethod(int methodId, bool isChecked)
{
if (isChecked) _selectedMethodIds.Add(methodId);
else _selectedMethodIds.Remove(methodId);
}
private static HashSet<int> ParseApprovedKeyIds(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return new HashSet<int>();
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
.Where(id => id > 0)
.ToHashSet();
}
private async Task CopyKeyToClipboard()
{
if (_newlyCreatedKeyValue == null) return;
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue);
_toast.ShowSuccess("Copied to clipboard.");
}
catch
{
_toast.ShowError("Copy failed.");
}
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}
@@ -0,0 +1,176 @@
@page "/admin/api-keys"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">API Key Management</h4>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/api-keys/create")'>Add API Key</button>
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name…"
@bind="_search" @bind:event="oninput" />
</div>
@if (_keys.Count == 0)
{
<p class="text-muted text-center">No API keys configured.</p>
}
else if (!FilteredKeys.Any())
{
<p class="text-muted small">No API keys match the filter.</p>
}
else
{
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Key Hash</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var key in FilteredKeys)
{
<tr @key="key.Id">
<td>@key.Id</td>
<td>
@key.Name
@if (!key.IsEnabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
</td>
<td><code>@MaskKeyValue(key.KeyHash)</code></td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2"
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
data-bs-toggle="dropdown"
aria-label="@($"More actions for {key.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item"
@onclick="() => ToggleKey(key)">
@(key.IsEnabled ? "Disable" : "Enable")
</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteKey(key)">
Delete
</button>
</li>
</ul>
</div>
</div>
</td>
</tr>
}
</tbody>
</table>
}
}
</div>
@code {
private List<ApiKey> _keys = new();
private bool _loading = true;
private string? _errorMessage;
private string _search = string.Empty;
private ToastNotification _toast = default!;
private IEnumerable<ApiKey> FilteredKeys =>
string.IsNullOrWhiteSpace(_search)
? _keys
: _keys.Where(k =>
k.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false);
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
_keys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load API keys: {ex.Message}";
}
_loading = false;
}
private static string MaskKeyValue(string keyValue)
{
if (keyValue.Length <= 8) return new string('*', keyValue.Length);
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
}
private async Task ToggleKey(ApiKey key)
{
try
{
key.IsEnabled = !key.IsEnabled;
await InboundApiRepository.UpdateApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
}
catch (Exception ex)
{
_toast.ShowError($"Toggle failed: {ex.Message}");
}
}
private async Task DeleteKey(ApiKey key)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete API Key",
$"Delete API key '{key.Name}'? This cannot be undone.",
danger: true);
if (!confirmed) return;
try
{
await InboundApiRepository.DeleteApiKeyAsync(key.Id);
await InboundApiRepository.SaveChangesAsync();
_toast.ShowSuccess($"API key '{key.Name}' deleted.");
await LoadDataAsync();
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
}
@@ -0,0 +1,226 @@
@page "/admin/ldap-mappings/create"
@page "/admin/ldap-mappings/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISecurityRepository SecurityRepository
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm"
aria-label="Back to LDAP mappings"
@onclick="GoBack">
&larr; Back
</button>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Mapping</h5>
<div class="mb-2">
<label class="form-label small">LDAP Group Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
</div>
<div class="mb-2">
<label class="form-label small">Role</label>
<select class="form-select form-select-sm" @bind="_formRole">
<option value="">Select role...</option>
<option value="Admin">Admin</option>
<option value="Design">Design</option>
<option value="Deployment">Deployment</option>
</select>
<div class="form-text">Deployment role: configure site scope below after saving.</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Site Scope Rules</h5>
@if (!IsEditMode)
{
<p class="text-muted small mb-0">Save the mapping first to configure site scope.</p>
}
else
{
@if (_scopeRules.Count > 0)
{
<div class="d-flex flex-wrap gap-2 mb-3">
@foreach (var rule in _scopeRules)
{
var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}";
<span class="badge bg-info text-dark d-inline-flex align-items-center">
@siteName
<button type="button"
class="btn-close btn-close-white ms-2"
style="font-size: 0.6rem;"
aria-label="@($"Remove scope rule for {siteName}")"
@onclick="() => DeleteScopeRule(rule)"></button>
</span>
}
</div>
}
else
{
<p class="text-muted small mb-3">All sites (no restrictions)</p>
}
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_scopeRuleSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm" @onclick="AddScopeRule">Add scope rule</button>
</div>
</div>
@if (_scopeRuleError != null)
{
<div class="text-danger small mt-2">@_scopeRuleError</div>
}
}
</div>
</div>
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => Id.HasValue;
private LdapGroupMapping? _editingMapping;
private string _formGroupName = string.Empty;
private string _formRole = string.Empty;
private string? _formError;
private List<SiteScopeRule> _scopeRules = new();
private List<Site> _sites = new();
private Dictionary<int, Site> _siteLookup = new();
private int _scopeRuleSiteId;
private string? _scopeRuleError;
protected override async Task OnInitializedAsync()
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_siteLookup = _sites.ToDictionary(s => s.Id);
if (Id.HasValue)
{
_editingMapping = await SecurityRepository.GetMappingByIdAsync(Id.Value);
if (_editingMapping != null)
{
_formGroupName = _editingMapping.LdapGroupName;
_formRole = _editingMapping.Role;
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
}
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
private async Task SaveMapping()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formGroupName))
{
_formError = "LDAP Group Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_formRole))
{
_formError = "Role is required.";
return;
}
try
{
if (_editingMapping != null)
{
_editingMapping.LdapGroupName = _formGroupName.Trim();
_editingMapping.Role = _formRole;
await SecurityRepository.UpdateMappingAsync(_editingMapping);
}
else
{
var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole);
await SecurityRepository.AddMappingAsync(mapping);
}
await SecurityRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task AddScopeRule()
{
_scopeRuleError = null;
if (_scopeRuleSiteId <= 0)
{
_scopeRuleError = "Select a site to add a scope rule.";
return;
}
try
{
var rule = new SiteScopeRule { LdapGroupMappingId = Id!.Value, SiteId = _scopeRuleSiteId };
await SecurityRepository.AddScopeRuleAsync(rule);
await SecurityRepository.SaveChangesAsync();
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
_scopeRuleSiteId = 0;
}
catch (Exception ex)
{
_scopeRuleError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteScopeRule(SiteScopeRule rule)
{
var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}";
var confirmed = await Dialog.ConfirmAsync(
"Remove Scope Rule",
$"Remove scope rule for '{siteName}'?",
danger: true);
if (!confirmed) return;
try
{
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
await SecurityRepository.SaveChangesAsync();
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id!.Value)).ToList();
}
catch (Exception ex)
{
_scopeRuleError = $"Delete failed: {ex.Message}";
}
}
}
@@ -0,0 +1,160 @@
@page "/admin/ldap-mappings"
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Security
@inject ISecurityRepository SecurityRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">LDAP Group Mappings</h4>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/ldap-mappings/create")'>Add Mapping</button>
</div>
@if (_loading)
{
<p class="text-muted">Loading...</p>
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name, LDAP group, or role…"
@bind="_search" @bind:event="oninput" />
</div>
@if (_mappings.Count == 0)
{
<p class="text-muted text-center">No mappings configured.</p>
}
else if (!FilteredMappings.Any())
{
<p class="text-muted small">No mappings match the filter.</p>
}
else
{
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>LDAP Group Name</th>
<th>Role</th>
<th>Site Scope</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var mapping in FilteredMappings)
{
var rules = _scopeRules.GetValueOrDefault(mapping.Id);
var ruleCount = rules?.Count ?? 0;
<tr @key="mapping.Id">
<td>@mapping.Id</td>
<td>@mapping.LdapGroupName</td>
<td><span class="badge bg-secondary">@mapping.Role</span></td>
<td>
@if (ruleCount > 0)
{
<span class="badge bg-info text-dark">@ruleCount rule(s)</span>
}
else
{
<span class="text-muted small">All sites</span>
}
</td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm py-0 px-2"
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
data-bs-toggle="dropdown"
aria-label="@($"More actions for {mapping.LdapGroupName}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteMapping(mapping.Id)">
Delete
</button>
</li>
</ul>
</div>
</div>
</td>
</tr>
}
</tbody>
</table>
}
}
</div>
@code {
private List<LdapGroupMapping> _mappings = new();
private Dictionary<int, List<SiteScopeRule>> _scopeRules = new();
private bool _loading = true;
private string? _errorMessage;
private string _search = string.Empty;
private IEnumerable<LdapGroupMapping> FilteredMappings =>
string.IsNullOrWhiteSpace(_search)
? _mappings
: _mappings.Where(m =>
(m.LdapGroupName?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) ||
(m.Role?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false));
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
_mappings = (await SecurityRepository.GetAllMappingsAsync()).ToList();
_scopeRules.Clear();
foreach (var mapping in _mappings)
{
var rules = await SecurityRepository.GetScopeRulesForMappingAsync(mapping.Id);
if (rules.Count > 0)
{
_scopeRules[mapping.Id] = rules.ToList();
}
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load mappings: {ex.Message}";
}
_loading = false;
}
private async Task DeleteMapping(int id)
{
try
{
// Also delete scope rules for this mapping
var rules = await SecurityRepository.GetScopeRulesForMappingAsync(id);
foreach (var rule in rules)
{
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
}
await SecurityRepository.DeleteMappingAsync(id);
await SecurityRepository.SaveChangesAsync();
await LoadDataAsync();
}
catch (Exception ex)
{
_errorMessage = $"Delete failed: {ex.Message}";
}
}
}
@@ -0,0 +1,176 @@
@page "/admin/sites/create"
@page "/admin/sites/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Communication
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">
&larr; Back
</button>
</div>
<ToastNotification @ref="_toast" />
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6>
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit
Log pre-filtered to this site's events. AuditEvent.SourceSiteId
stores the SiteIdentifier (string), so we pass that through. *@
@if (IsEditMode && !string.IsNullOrWhiteSpace(_formIdentifier))
{
<a class="btn btn-outline-secondary btn-sm"
href="/audit/log?site=@Uri.EscapeDataString(_formIdentifier)"
data-test="audit-link">
Recent audit activity
</a>
}
</div>
<div class="mb-2">
<label class="form-label small">Identifier</label>
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
disabled="@IsEditMode" />
</div>
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="mb-3">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_formDescription" />
</div>
<h6 class="text-muted border-bottom pb-1">Node A</h6>
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeAAddress"
placeholder="akka.tcp://scadabridge@host:port/user/site-communication" />
</div>
<div class="mb-3">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
placeholder="http://host:8083" />
</div>
<h6 class="text-muted border-bottom pb-1">Node B</h6>
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeBAddress"
placeholder="akka.tcp://scadabridge@host:port/user/site-communication" />
</div>
<div class="mb-3">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
placeholder="http://host:8083" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSite">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => Id.HasValue;
private Site? _editingSite;
private string _formName = string.Empty;
private string _formIdentifier = string.Empty;
private string? _formDescription;
private string? _formNodeAAddress;
private string? _formNodeBAddress;
private string? _formGrpcNodeAAddress;
private string? _formGrpcNodeBAddress;
private string? _formError;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_editingSite = await SiteRepository.GetSiteByIdAsync(Id.Value);
if (_editingSite != null)
{
_formName = _editingSite.Name;
_formIdentifier = _editingSite.SiteIdentifier;
_formDescription = _editingSite.Description;
_formNodeAAddress = _editingSite.NodeAAddress;
_formNodeBAddress = _editingSite.NodeBAddress;
_formGrpcNodeAAddress = _editingSite.GrpcNodeAAddress;
_formGrpcNodeBAddress = _editingSite.GrpcNodeBAddress;
}
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/sites");
}
private async Task SaveSite()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName))
{
_formError = "Name is required.";
return;
}
try
{
if (_editingSite != null)
{
_editingSite.Name = _formName.Trim();
_editingSite.Description = _formDescription?.Trim();
_editingSite.NodeAAddress = _formNodeAAddress?.Trim();
_editingSite.NodeBAddress = _formNodeBAddress?.Trim();
_editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim();
_editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim();
await SiteRepository.UpdateSiteAsync(_editingSite);
}
else
{
if (string.IsNullOrWhiteSpace(_formIdentifier))
{
_formError = "Identifier is required.";
return;
}
var site = new Site(_formName.Trim(), _formIdentifier.Trim())
{
Description = _formDescription?.Trim(),
NodeAAddress = _formNodeAAddress?.Trim(),
NodeBAddress = _formNodeBAddress?.Trim(),
GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(),
GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim()
};
await SiteRepository.AddSiteAsync(site);
}
await SiteRepository.SaveChangesAsync();
CommunicationService.RefreshSiteAddresses();
NavigationManager.NavigateTo("/admin/sites");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
}
@@ -0,0 +1,325 @@
@* Reference pattern for list pages: card grid (col-lg-6) + flex header + search filter + kebab dropdown + Bootstrap collapse for noisy detail + @key on iterated cards + "No X match the filter." inline + empty-state CTA. Mirror this when building new list pages. *@
@page "/admin/sites"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Communication
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject ArtifactDeploymentService ArtifactDeploymentService
@inject CommunicationService CommunicationService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
@inject IDialogService Dialog
@inject Microsoft.Extensions.Logging.ILogger<Sites> Logger
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Management</h4>
<div class="d-flex gap-2">
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
data-bs-toggle="dropdown" disabled="@_deploying">
@if (_deploying)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Bulk actions
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="DeployArtifactsToAllSites">
Deploy Artifacts to All Sites
</button>
</li>
</ul>
</div>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>
+ Add Site
</button>
</div>
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else if (_sites.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No sites configured.</p>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>
Add your first site
</button>
</div>
}
else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name or identifier…"
@bind="_search" @bind:event="oninput" />
</div>
@if (!FilteredSites.Any())
{
<p class="text-muted small">No sites match the filter.</p>
}
<div class="row g-3">
@foreach (var site in FilteredSites)
{
var conns = _siteConnections.GetValueOrDefault(site.Id);
var collapseId = $"cluster-{site.Id}";
<div class="col-lg-6 col-12" @key="site.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-1">@site.Name</h5>
<code class="small">@site.SiteIdentifier</code>
</div>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>
Edit
</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown" aria-label="More actions">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item"
@onclick="() => DeployArtifacts(site)"
disabled="@_deploying">
Deploy Artifacts
</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteSite(site)">
Delete
</button>
</li>
</ul>
</div>
</div>
</div>
<p class="text-muted small mb-3">
@(string.IsNullOrWhiteSpace(site.Description) ? "—" : site.Description)
</p>
<div class="small text-muted mb-1">Data connections</div>
@if (conns is { Count: > 0 })
{
<ul class="list-unstyled mb-3">
@foreach (var c in conns)
{
<li class="mb-1">
<span class="badge bg-info text-dark me-1">@c.Protocol</span>
@c.Name
</li>
}
</ul>
}
else
{
<p class="text-muted small fst-italic mb-3">No connections.</p>
}
<button class="btn btn-link btn-sm p-0 text-decoration-none"
data-bs-toggle="collapse"
data-bs-target="@($"#{collapseId}")"
aria-expanded="false">
Cluster nodes (Akka, gRPC)
</button>
<div class="collapse mt-2" id="@collapseId">
@ClusterRow("Node A", site.NodeAAddress)
@ClusterRow("Node B", site.NodeBAddress)
@ClusterRow("gRPC A", site.GrpcNodeAAddress)
@ClusterRow("gRPC B", site.GrpcNodeBAddress)
</div>
</div>
</div>
</div>
}
</div>
}
</div>
@code {
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private List<Site> _sites = new();
private Dictionary<int, List<DataConnection>> _siteConnections = new();
private bool _loading = true;
private string? _errorMessage;
private bool _deploying;
private string _search = "";
private ToastNotification _toast = default!;
private IEnumerable<Site> FilteredSites =>
string.IsNullOrWhiteSpace(_search)
? _sites
: _sites.Where(s =>
(s.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) ||
(s.SiteIdentifier?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false));
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
// CentralUI-012: fetch all data connections in one query and group
// them by site, instead of issuing one query per site (N+1).
_siteConnections = (await SiteRepository.GetAllDataConnectionsAsync())
.GroupBy(c => c.SiteId)
.ToDictionary(g => g.Key, g => g.ToList());
}
catch (Exception ex)
{
_errorMessage = $"Failed to load sites: {ex.Message}";
}
_loading = false;
}
private async Task DeleteSite(Site site)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Site",
$"Delete site '{site.Name}' ({site.SiteIdentifier})? This cannot be undone.",
danger: true);
if (!confirmed) return;
try
{
await SiteRepository.DeleteSiteAsync(site.Id);
await SiteRepository.SaveChangesAsync();
CommunicationService.RefreshSiteAddresses();
_toast.ShowSuccess($"Site '{site.Name}' deleted.");
await LoadDataAsync();
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
private async Task DeployArtifacts(Site site)
{
_deploying = true;
try
{
var user = await GetCurrentUserAsync();
var result = await ArtifactDeploymentService.RetryForSiteAsync(
site.Id, site.SiteIdentifier, user);
if (result.IsSuccess)
_toast.ShowSuccess($"Artifacts deployed to '{site.Name}'.");
else
_toast.ShowError($"Deploy to '{site.Name}' failed: {result.Error}");
}
catch (Exception ex)
{
_toast.ShowError($"Deploy to '{site.Name}' failed: {ex.Message}");
}
finally
{
_deploying = false;
}
}
private async Task DeployArtifactsToAllSites()
{
_deploying = true;
try
{
var user = await GetCurrentUserAsync();
var result = await ArtifactDeploymentService.DeployToAllSitesAsync(user);
if (result.IsSuccess)
{
var summary = result.Value!;
_toast.ShowSuccess(
$"Artifacts deployed: {summary.SuccessCount} succeeded, {summary.FailureCount} failed.");
}
else
{
_toast.ShowError($"Artifact deployment failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Artifact deployment failed: {ex.Message}");
}
finally
{
_deploying = false;
}
}
private RenderFragment ClusterRow(string label, string? address) => __builder =>
{
<div class="row g-1 align-items-center mb-1">
<div class="col-2 small text-muted">@label</div>
<div class="col-9">
<code class="small d-block text-truncate" title="@address">
@(string.IsNullOrWhiteSpace(address) ? "—" : address)
</code>
</div>
<div class="col-1 text-end">
@if (!string.IsNullOrWhiteSpace(address))
{
<button class="btn btn-link btn-sm p-0"
@onclick="() => CopyAsync(address!)" title="Copy">📋</button>
}
</div>
</div>
};
private async Task CopyAsync(string text)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
_toast.ShowSuccess("Copied to clipboard.");
}
catch (Microsoft.JSInterop.JSDisconnectedException)
{
// Circuit gone — the user has navigated away; nothing to surface.
}
catch (Microsoft.JSInterop.JSException ex)
{
// CentralUI-018: a real clipboard failure (e.g. permission denied)
// is logged, not silently swallowed.
Logger.LogWarning(ex, "Clipboard copy failed.");
_toast.ShowError("Copy failed.");
}
}
}
@@ -0,0 +1,61 @@
@page "/audit/log"
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Security
@inject IAuditLogQueryService AuditLogQueryService
<PageTitle>Audit Log</PageTitle>
<div class="container-fluid mt-3">
<h1 class="h4 mb-3">Audit Log</h1>
@* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid.
Bundle D (M7-T10..T12) threads a query-string instance prefill through
InitialInstanceSearch — UI-only because the filter contract has no instance column. *@
<div class="mb-3">
<AuditFilterBar OnFilterChanged="HandleFilterChanged"
InitialInstanceSearch="@_initialInstanceSearch" />
</div>
@* Export button (Bundle F / M7-T14). A plain <a download> link triggers the
streaming CSV endpoint at /api/centralui/audit/export — chosen over a
SignalR-driven download because the request can stream 100k rows directly
to the response body without buffering through the Blazor circuit. The
href reflects the most recently applied filter; before Apply is clicked,
an unconstrained export is exposed.
Bundle G (#23 M7-T15) gates the button on the AuditExport policy so an
OperationalAudit-only operator (read access without bulk export) sees the
page + filters but cannot trigger the CSV pull. The endpoint itself is
gated separately, so a hand-crafted URL still 403s — the AuthorizeView
here is the user-facing affordance, not the authoritative check. *@
<AuthorizeView Policy="@AuthorizationPolicies.AuditExport">
<Authorized Context="exportContext">
<div class="mb-3 d-flex justify-content-end">
<a class="btn btn-outline-secondary btn-sm"
href="@ExportUrl"
download
role="button"
aria-label="Export current view to CSV">
Export CSV
</a>
</div>
</Authorized>
</AuthorizeView>
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's
drilldown drawer; the grid stays in "no events" mode until the user applies a
filter so the page does not auto-load the full audit table on first render. *@
<div>
<AuditResultsGrid Filter="@_currentFilter" OnRowSelected="HandleRowSelected" />
</div>
</div>
@* Drilldown drawer (Bundle C / M7-T4..T8). Hosted at the page level so the
off-canvas overlay sits above the grid / filter bar irrespective of scroll. *@
<AuditDrilldownDrawer Event="@_selectedEvent"
IsOpen="@_drawerOpen"
OnClose="HandleDrawerClose" />
@@ -0,0 +1,338 @@
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.WebUtilities;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit;
/// <summary>
/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3)
/// wires up <c>AuditFilterBar</c> and <c>AuditResultsGrid</c>: the page owns the
/// active <see cref="AuditLogQueryFilter"/> and re-pushes a fresh instance to the
/// grid on every Apply (the grid uses reference identity as its "reload"
/// trigger). Row clicks land in <see cref="HandleRowSelected"/> — Bundle C wires
/// this to the drilldown drawer; for now it is a no-op seam so test stubs do
/// not error.
///
/// <para>
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
/// <c>?executionId=</c> for the "View this execution" drill-in, and the
/// ParentExecutionId follow-up adds <c>?parentExecutionId=</c> for the
/// "View parent execution" drill-in. When any param is present we allocate a
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
/// are silently dropped — the page still renders, just without that constraint.
/// </para>
///
/// <para>
/// Query-string filters are re-applied on every <see cref="NavigationManager.LocationChanged"/>,
/// not just on init. The drilldown drawer's "View this/parent execution" actions
/// navigate to <c>/audit/log?executionId=…</c> while the user is ALREADY on this
/// routed page — Blazor treats that as a same-component navigation, so
/// <see cref="OnInitialized"/> does not re-run. Without the
/// <see cref="NavigationManager.LocationChanged"/> subscription the URL would
/// change but <see cref="_currentFilter"/> would stay stale and the grid would
/// never reload to the new drill-in. The subscription is disposed via
/// <see cref="IDisposable"/>.
/// </para>
/// </summary>
public partial class AuditLogPage : IDisposable
{
[Inject] private NavigationManager Navigation { get; set; } = null!;
private AuditLogQueryFilter? _currentFilter;
private AuditEvent? _selectedEvent;
private bool _drawerOpen;
private string? _initialInstanceSearch;
/// <inheritdoc />
protected override void OnInitialized()
{
ApplyQueryStringFilters();
Navigation.LocationChanged += HandleLocationChanged;
}
/// <summary>
/// Re-applies the query-string drill-in filters when the URL changes while
/// this page stays routed (e.g. the drawer's "View parent execution" action
/// navigates to <c>/audit/log?executionId=…</c>). Reassigning
/// <see cref="_currentFilter"/> to a fresh instance is what kicks the results
/// grid into reloading; we also close the drawer so the operator sees the
/// newly filtered grid. The body is marshalled through
/// <see cref="ComponentBase.InvokeAsync(Action)"/> because
/// <see cref="NavigationManager.LocationChanged"/> can fire off the renderer's
/// synchronization context.
/// </summary>
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
{
_ = InvokeAsync(() =>
{
ApplyQueryStringFilters();
_drawerOpen = false;
StateHasChanged();
});
}
/// <summary>Unsubscribes from navigation events to prevent memory leaks when the component is removed.</summary>
public void Dispose()
{
Navigation.LocationChanged -= HandleLocationChanged;
}
private void ApplyQueryStringFilters()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
// A paramless navigation (e.g. clicking the "Audit Log" nav link while
// already here) intentionally preserves the last applied filter rather
// than clearing the grid: this method is a drill-in mechanism and every
// drill-in carries query params. The operator clears via the filter bar.
if (query.Count == 0)
{
return;
}
Guid? correlationId = null;
if (query.TryGetValue("correlationId", out var corrValues)
&& Guid.TryParse(corrValues.ToString(), out var parsedCorr))
{
correlationId = parsedCorr;
}
// ?executionId= is the "View this execution" drill-in target — the
// universal per-run correlation value. Lax-parsed like ?correlationId=:
// an unparseable value is silently dropped (no constraint).
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
// ?parentExecutionId= constrains to runs spawned by a given execution.
// Lax-parsed like ?executionId=: an unparseable value is silently dropped.
Guid? parentExecutionId = null;
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
{
parentExecutionId = parsedParentExec;
}
string? target = null;
if (query.TryGetValue("target", out var targetValues))
{
var v = targetValues.ToString();
if (!string.IsNullOrWhiteSpace(v))
{
target = v.Trim();
}
}
string? actor = null;
if (query.TryGetValue("actor", out var actorValues))
{
var v = actorValues.ToString();
if (!string.IsNullOrWhiteSpace(v))
{
actor = v.Trim();
}
}
// site/channel/kind/status accept repeated params for symmetry with the
// multi-value export URL — a single ?site=/?channel=/?kind=/?status=
// drill-in still works (one-element list). Unknown enum names are silently
// dropped. The lax-parse contract is shared with the two export endpoints
// via AuditQueryParamParsers so all three surfaces stay in lockstep.
IReadOnlyList<string>? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site"));
IReadOnlyList<AuditChannel>? channels =
AuditQueryParamParsers.ParseEnumList<AuditChannel>(Raw(query, "channel"));
// ?kind= is honored for symmetry with BuildExportUrl, which emits a kind=
// param — a kind drill-in deep link must round-trip back into the filter.
IReadOnlyList<AuditKind>? kinds =
AuditQueryParamParsers.ParseEnumList<AuditKind>(Raw(query, "kind"));
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in
// with ?status=Failed (and operators may craft URLs with Parked/Discarded).
// Unknown values are silently dropped — the page still renders without
// the constraint.
IReadOnlyList<AuditStatus>? statuses =
AuditQueryParamParsers.ParseEnumList<AuditStatus>(Raw(query, "status"));
// Instance is UI-only — the filter contract has no matching column, so we
// pass it as a separate seam to the filter bar.
if (query.TryGetValue("instance", out var instanceValues))
{
var v = instanceValues.ToString();
if (!string.IsNullOrWhiteSpace(v))
{
_initialInstanceSearch = v.Trim();
}
}
// If ANY filter-shaped param was provided, allocate the filter so the grid
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
// because the filter contract has no instance column — the user still needs
// to refine + Apply for those.
if (correlationId is null && executionId is null && parentExecutionId is null
&& target is null && actor is null
&& sites is null && channels is null && kinds is null && statuses is null)
{
return;
}
_currentFilter = new AuditLogQueryFilter(
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sites,
Target: target,
Actor: actor,
CorrelationId: correlationId,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId);
}
/// <summary>
/// Extracts the raw repeated values for one query-string key, returning
/// <c>null</c> when the key is absent so the shared
/// <see cref="AuditQueryParamParsers"/> sees the same absent-vs-present
/// distinction the ASP.NET <c>IQueryCollection</c> callers do.
/// <c>StringValues</c> is itself an <c>IEnumerable&lt;string?&gt;</c>.
/// </summary>
private static IEnumerable<string?>? Raw(
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
query.TryGetValue(key, out var values) ? (IEnumerable<string?>)values : null;
private void HandleFilterChanged(AuditLogQueryFilter filter)
{
// Always reassign — the grid keys reloads on reference change, so even a
// chip-for-chip identical filter must allocate a fresh instance.
_currentFilter = filter;
}
private void HandleRowSelected(AuditEvent row)
{
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as
// the selected row and open the drilldown drawer — the drawer is fully
// presentational so we do not need to refetch the row.
_selectedEvent = row;
_drawerOpen = true;
}
private void HandleDrawerClose()
{
// We deliberately keep _selectedEvent set so re-opening (e.g. via the
// grid) shows the same row instantly without a re-render flicker.
_drawerOpen = false;
}
/// <summary>
/// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most
/// recently applied filter as query-string params so the server-side
/// streaming endpoint reproduces the user's current view. With no filter
/// applied yet, returns the bare endpoint — i.e. an unconstrained export.
/// </summary>
/// <remarks>
/// Built here rather than in markup so the per-row test coverage can
/// exercise the URL composition without booting the full Blazor renderer.
/// </remarks>
internal string ExportUrl => BuildExportUrl(_currentFilter);
/// <summary>
/// Builds the CSV export URL for the given filter, encoding all active filter dimensions as query parameters.
/// </summary>
/// <param name="filter">Currently applied filter; null returns the bare export endpoint.</param>
internal static string BuildExportUrl(AuditLogQueryFilter? filter)
{
const string basePath = "/api/centralui/audit/export";
if (filter is null)
{
return basePath;
}
// No capacity hint: the dimensions are multi-value, so the part count is
// unbounded by the number of filter fields.
var parts = new List<KeyValuePair<string, string?>>();
// Task 9: the filter dimensions are multi-value end-to-end. Emit ONE
// repeated query-string key per selected value (channel=A&channel=B); the
// export endpoint's ParseFilter reads the full repeated set.
if (filter.Channels is { Count: > 0 } channels)
{
foreach (var channel in channels)
{
parts.Add(new("channel", channel.ToString()));
}
}
if (filter.Kinds is { Count: > 0 } kinds)
{
foreach (var kind in kinds)
{
parts.Add(new("kind", kind.ToString()));
}
}
if (filter.Statuses is { Count: > 0 } statuses)
{
foreach (var status in statuses)
{
parts.Add(new("status", status.ToString()));
}
}
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
{
foreach (var site in sourceSiteIds)
{
if (!string.IsNullOrWhiteSpace(site))
{
parts.Add(new("site", site));
}
}
}
if (!string.IsNullOrWhiteSpace(filter.Target))
{
parts.Add(new("target", filter.Target));
}
if (!string.IsNullOrWhiteSpace(filter.Actor))
{
parts.Add(new("actor", filter.Actor));
}
if (filter.CorrelationId is { } corr)
{
parts.Add(new("correlationId", corr.ToString()));
}
if (filter.ExecutionId is { } exec)
{
parts.Add(new("executionId", exec.ToString()));
}
if (filter.ParentExecutionId is { } parentExec)
{
parts.Add(new("parentExecutionId", parentExec.ToString()));
}
if (filter.FromUtc is { } from)
{
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
}
if (filter.ToUtc is { } to)
{
parts.Add(new("to", to.ToString("O", CultureInfo.InvariantCulture)));
}
if (parts.Count == 0)
{
return basePath;
}
return QueryHelpers.AddQueryString(basePath, parts);
}
}
@@ -0,0 +1,391 @@
@page "/audit/configuration"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@inject ICentralUiRepository CentralUiRepository
@inject NavigationManager Nav
@inject IJSRuntime JS
<div class="container-fluid mt-3">
<h4 class="mb-3">Configuration Audit Log</h4>
<ToastNotification @ref="_toast" />
@* Bundle Import filter chip (T24). Set via ?bundleImportId={guid} query
string so drill-ins from the Import wizard / other pages can scope this
page to a single import run. Cleared via the × button, which navigates
back to the page without the query param so the user sees all rows. *@
@if (BundleImportId is Guid bundleId)
{
<div class="mb-3">
<span class="badge bg-primary p-2">
Filtered by Bundle Import: <code class="text-light">@bundleId.ToString()[..8]</code>
<button type="button"
class="btn-close btn-close-white btn-sm ms-2"
aria-label="Clear Bundle Import filter"
@onclick="ClearBundleImportFilter"></button>
</span>
</div>
}
<div class="row mb-3 g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small" for="audit-filter-user">User</label>
<input id="audit-filter-user"
type="text"
class="form-control form-control-sm"
aria-label="User"
@bind="_filterUser"
placeholder="Username" />
</div>
<div class="col-md-2">
<label class="form-label small" for="audit-filter-entity-type">Entity Type</label>
<input id="audit-filter-entity-type"
type="text"
class="form-control form-control-sm"
aria-label="Entity type"
@bind="_filterEntityType"
placeholder="e.g. Template" />
</div>
<div class="col-md-2">
<label class="form-label small" for="audit-filter-action">Action</label>
<input id="audit-filter-action"
type="text"
class="form-control form-control-sm"
aria-label="Action"
@bind="_filterAction"
placeholder="e.g. Create" />
</div>
<div class="col-md-2">
<label class="form-label small" for="audit-filter-from">From</label>
<input id="audit-filter-from"
type="datetime-local"
class="form-control form-control-sm"
aria-label="From timestamp"
@bind="_filterFrom" />
</div>
<div class="col-md-2">
<label class="form-label small" for="audit-filter-to">To</label>
<input id="audit-filter-to"
type="datetime-local"
class="form-control form-control-sm"
aria-label="To timestamp"
@bind="_filterTo" />
</div>
<div class="col-md-2 d-flex gap-1">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters" disabled="@_searching">
Clear filters
</button>
</div>
</div>
@if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
@if (_entries != null)
{
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Action</th>
<th>Entity Type</th>
<th>Entity ID</th>
<th>Entity Name</th>
<th>State</th>
</tr>
</thead>
<tbody>
@if (_entries.Count == 0)
{
<tr><td colspan="7" class="text-muted text-center">No audit entries found.</td></tr>
}
@foreach (var entry in _entries)
{
var entityIdShort = entry.EntityId is { Length: > 0 }
? entry.EntityId[..Math.Min(12, entry.EntityId.Length)]
: "";
var hasState = !string.IsNullOrWhiteSpace(entry.AfterStateJson);
var isLarge = hasState && entry.AfterStateJson!.Length > 1024;
<tr>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small">@entry.User</td>
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
<td class="small">@entry.EntityType</td>
<td class="small">
@if (!string.IsNullOrEmpty(entry.EntityId))
{
<code>@entityIdShort…</code>
<button class="btn btn-link btn-sm p-0 ms-1"
@onclick="() => CopyAsync(entry.EntityId)"
title="Copy entity ID"
aria-label="Copy entity ID @entry.EntityId">📋</button>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="small">@entry.EntityName</td>
<td>
@if (hasState)
{
if (isLarge)
{
<button class="btn btn-outline-info btn-sm py-0 px-1"
@onclick="() => ShowStateModal(entry)"
aria-label="Open state details in modal for audit entry @entry.Id">
View in modal
</button>
}
else
{
<button class="btn btn-outline-info btn-sm py-0 px-1"
@onclick="() => ToggleStateView(entry.Id)"
aria-label="Toggle state details for audit entry @entry.Id">
@(_expandedEntryId == entry.Id ? "Hide" : "View")
</button>
}
}
else
{
<span class="text-muted small">—</span>
}
</td>
</tr>
@if (hasState && !isLarge && _expandedEntryId == entry.Id)
{
<tr>
<td colspan="7">
<pre class="bg-light p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson!)</pre>
</td>
</tr>
}
}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Page @_page of @TotalPages (@_totalCount total)</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1" @onclick="PrevPage" disabled="@(_page <= 1)">Previous</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextPage" disabled="@(!HasMore)">Next</button>
</div>
</div>
}
@if (_modalEntry != null)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Audit entry @_modalEntry.Id — @_modalEntry.EntityType state
</h5>
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
</div>
<div class="modal-body">
<pre class="bg-light p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
</div>
</div>
</div>
</div>
}
</div>
@code {
/// <summary>
/// T24 (Transport). When non-null, scopes the page to a single bundle
/// import run. Set via the <c>?bundleImportId=</c> query string from
/// drill-ins (Import wizard summary, future BundleImported row links).
/// </summary>
[SupplyParameterFromQuery, Parameter] public Guid? BundleImportId { get; set; }
private string? _filterUser;
private string? _filterEntityType;
private string? _filterAction;
private DateTime? _filterFrom;
private DateTime? _filterTo;
// The datetime-local filter inputs are in the browser's local time zone.
// This holds new Date().getTimezoneOffset() so the values are converted to
// UTC (CentralUI-008) rather than relabelled. Until JS interop runs it is 0
// (UTC), which is a safe default for a UTC server/browser.
private int _browserUtcOffsetMinutes;
private List<AuditLogEntry>? _entries;
private int _totalCount;
private int _page = 1;
private int _pageSize = 50;
private bool _searching;
private string? _errorMessage;
private int? _expandedEntryId;
private AuditLogEntry? _modalEntry;
private ToastNotification _toast = default!;
private int TotalPages => _pageSize > 0 ? Math.Max(1, (_totalCount + _pageSize - 1) / _pageSize) : 1;
private bool HasMore => _page * _pageSize < _totalCount;
// Tracks the BundleImportId we last fetched against so a re-render with the
// same query param doesn't re-run the query on every parameter set.
private Guid? _lastFetchedBundleImportId;
// CentralUI-029: the browser-time JS module that hosts getTimezoneOffsetMinutes().
// Loaded lazily on first render via dynamic import; replaces the previous
// `JS.InvokeAsync<int>("eval", "new Date().getTimezoneOffset()")` call, which
// widened the JS-interop attack surface and was incompatible with strict CSP
// `script-src` directives that forbid `unsafe-eval`.
private const string BrowserTimeModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/browser-time.js";
private IJSObjectReference? _browserTimeModule;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
try
{
// Date.getTimezoneOffset() returns (UTC - local) in minutes.
_browserTimeModule ??= await JS.InvokeAsync<IJSObjectReference>(
"import", BrowserTimeModulePath);
_browserUtcOffsetMinutes = await _browserTimeModule.InvokeAsync<int>(
"getTimezoneOffsetMinutes");
}
catch (Exception ex) when (ex is JSException or JSDisconnectedException
or InvalidOperationException or TaskCanceledException)
{
// Prerender or a disconnected circuit: fall back to UTC (offset 0).
_browserUtcOffsetMinutes = 0;
}
}
protected override async Task OnParametersSetAsync()
{
// T24: when the BundleImportId query param is set (or cleared), refetch
// automatically so the user lands on a pre-filtered page from a drill-in
// link without having to click Search.
if (BundleImportId != _lastFetchedBundleImportId)
{
_lastFetchedBundleImportId = BundleImportId;
_page = 1;
await FetchPage();
}
}
private void ClearBundleImportFilter()
{
// Strip the query param by navigating to the bare page route. The
// resulting OnParametersSetAsync run will refetch with BundleImportId
// back to null.
Nav.NavigateTo("/audit/configuration");
}
private async Task Search()
{
_page = 1;
await FetchPage();
}
private async Task ClearFilters()
{
_filterUser = null;
_filterEntityType = null;
_filterAction = null;
_filterFrom = null;
_filterTo = null;
_page = 1;
await FetchPage();
}
private async Task PrevPage() { _page--; await FetchPage(); }
private async Task NextPage() { _page++; await FetchPage(); }
private async Task FetchPage()
{
_searching = true;
_errorMessage = null;
try
{
var (entries, totalCount) = await CentralUiRepository.GetAuditLogEntriesAsync(
user: string.IsNullOrWhiteSpace(_filterUser) ? null : _filterUser.Trim(),
entityType: string.IsNullOrWhiteSpace(_filterEntityType) ? null : _filterEntityType.Trim(),
action: string.IsNullOrWhiteSpace(_filterAction) ? null : _filterAction.Trim(),
from: BrowserTime.LocalInputToUtc(_filterFrom, _browserUtcOffsetMinutes),
to: BrowserTime.LocalInputToUtc(_filterTo, _browserUtcOffsetMinutes),
bundleImportId: BundleImportId,
page: _page,
pageSize: _pageSize);
_entries = entries.ToList();
_totalCount = totalCount;
}
catch (Exception ex)
{
_errorMessage = $"Query failed: {ex.Message}";
}
_searching = false;
}
private void ToggleStateView(int entryId)
{
_expandedEntryId = _expandedEntryId == entryId ? null : entryId;
}
private void ShowStateModal(AuditLogEntry entry)
{
_modalEntry = entry;
}
private void CloseStateModal()
{
_modalEntry = null;
}
private async Task CopyAsync(string text)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
_toast.ShowSuccess("Copied to clipboard.");
}
catch
{
_toast.ShowError("Copy failed.");
}
}
private static string GetActionBadge(string action) => action switch
{
"Create" => "bg-success",
"Update" => "bg-primary",
"Delete" => "bg-danger",
"Deploy" => "bg-info text-dark",
_ => "bg-secondary"
};
private static string FormatJson(string json)
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(json);
return System.Text.Json.JsonSerializer.Serialize(doc, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}
catch
{
return json;
}
}
}
@@ -0,0 +1,70 @@
@page "/audit/execution-tree"
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Security
@inject IAuditLogQueryService AuditLogQueryService
<PageTitle>Execution Chain</PageTitle>
@* Execution-chain tree view (Audit Log ParentExecutionId feature, Task 10).
A drill-in target reached from the Audit Log drawer's "View execution chain"
action: /audit/execution-tree?executionId={guid}. The page parses the id,
asks the query service for the whole chain (flat ExecutionTreeNode list), and
hands it to the recursive ExecutionTree component. There is deliberately NO
nav-menu entry — this page is only meaningful in the context of a specific
execution, so it is reachable only via drill-in (the Audit nav group keeps
just the Audit Log + Configuration Audit Log pages). *@
<div class="container-fluid mt-3">
<h1 class="h4 mb-1">Execution Chain</h1>
<p class="text-muted small mb-3">
The full chain of script / inbound-request executions linked by
<span class="font-monospace">ParentExecutionId</span>, rooted at the
topmost ancestor. Select an execution to open the Audit Log filtered to
its rows.
</p>
@if (_executionId is null)
{
@* No (or unparseable) ?executionId= — render guidance rather than an
empty tree. Mirrors the Audit Log page's silently-drop contract. *@
<div class="alert alert-secondary small" data-test="execution-tree-no-id">
No execution selected. Open this view from an audit row's
<strong>View execution chain</strong> action.
</div>
}
else if (_loading)
{
<div class="text-muted small" data-test="execution-tree-loading">Loading execution chain…</div>
}
else if (_error is not null)
{
<div class="alert alert-danger small" data-test="execution-tree-error">@_error</div>
}
else if (_nodes is { Count: > 0 })
{
<div class="mb-2">
<a class="btn btn-outline-secondary btn-sm"
data-test="execution-tree-back-to-log"
href="@($"/audit/log?executionId={_executionId}")">
View this execution in the Audit Log
</a>
</div>
<ExecutionTree Nodes="_nodes" ArrivedFromExecutionId="_executionId.Value"
OnNodeActivated="HandleNodeActivated" />
@* Double-clicking a tree node raises OnNodeActivated, which opens this
modal for that execution. The modal renders nothing while IsOpen is
false, so it is safe to place unconditionally here. *@
<ExecutionDetailModal ExecutionId="_modalExecutionId" IsOpen="_modalOpen"
OnClose="HandleModalClose" />
}
else
{
<div class="alert alert-secondary small" data-test="execution-tree-empty">
No execution chain found for this id.
</div>
}
</div>
@@ -0,0 +1,121 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit;
/// <summary>
/// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId
/// feature, Task 10). Route <c>/audit/execution-tree</c>, reached via the Audit
/// Log drilldown drawer's "View execution chain" action with
/// <c>?executionId={guid}</c>.
///
/// <para>
/// On initialization the page parses <c>?executionId=</c> (lax-parsed, matching
/// the Audit Log page's drill-in contract — an absent or unparseable value
/// leaves the page in a guidance state and issues NO service call), then asks
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Services.IAuditLogQueryService.GetExecutionTreeAsync"/>
/// for the whole chain. The flat <see cref="ExecutionTreeNode"/> list is handed
/// to the recursive <c>ExecutionTree</c> component, which assembles + renders
/// the tree.
/// </para>
///
/// <para>
/// The data path mirrors the Audit Log results grid: the page talks ONLY to the
/// CentralUI <c>IAuditLogQueryService</c> facade, never <c>IAuditLogRepository</c>
/// directly, so the page can be unit-tested with a substituted service.
/// </para>
/// </summary>
public partial class ExecutionTreePage
{
[Inject] private NavigationManager Navigation { get; set; } = null!;
// The parsed ?executionId= value, or null when absent / unparseable.
private Guid? _executionId;
// The flat chain returned by the query service; null until the load
// completes (or when no id was supplied).
private IReadOnlyList<ExecutionTreeNode>? _nodes;
private bool _loading;
private string? _error;
// Execution-Tree Node Detail Modal feature (Task 4) — state backing the
// <ExecutionDetailModal>. A double-click on a tree node sets
// _modalExecutionId + flips _modalOpen true; the modal loads that
// execution's audit rows on the closed → open transition. _modalOpen is the
// visibility gate — _modalExecutionId is left intact across a close (it is
// harmless while the modal is hidden and avoids a flicker if reopened).
private Guid? _modalExecutionId;
private bool _modalOpen;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
_executionId = ParseExecutionId();
if (_executionId is null)
{
// No id — render guidance, do not touch the service.
return;
}
await LoadChainAsync(_executionId.Value);
}
/// <summary>
/// Lax-parses <c>?executionId=</c>. Returns null when the param is absent or
/// is not a valid <see cref="Guid"/> — the page then shows guidance instead
/// of an error, consistent with the Audit Log page's drill-in handling.
/// </summary>
private Guid? ParseExecutionId()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.TryGetValue("executionId", out var values)
&& Guid.TryParse(values.ToString(), out var parsed))
{
return parsed;
}
return null;
}
private async Task LoadChainAsync(Guid executionId)
{
_loading = true;
_error = null;
try
{
_nodes = await AuditLogQueryService.GetExecutionTreeAsync(executionId);
}
catch (Exception ex)
{
// A transient DB outage degrades this page to an error banner
// rather than killing the circuit — the same defensive posture the
// Audit Log grid takes around its query.
_error = $"Could not load the execution chain: {ex.Message}";
_nodes = null;
}
finally
{
_loading = false;
}
}
/// <summary>
/// Raised by <c>ExecutionTree</c> (bubbled up from a node double-click) with
/// the activated node's <c>ExecutionId</c>. Opens the
/// <c>ExecutionDetailModal</c> for that execution — the modal loads its
/// audit rows on the closed → open transition.
/// </summary>
private void HandleNodeActivated(Guid executionId)
{
_modalExecutionId = executionId;
_modalOpen = true;
}
/// <summary>
/// Raised by <c>ExecutionDetailModal</c> when the user dismisses it. Flips
/// the visibility gate closed; <see cref="_modalExecutionId"/> is left as-is.
/// </summary>
private void HandleModalClose() => _modalOpen = false;
}
@@ -0,0 +1,119 @@
@page "/"
@attribute [Authorize]
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@inject ISiteRepository SiteRepository
@inject ITemplateEngineRepository TemplateEngineRepository
@inject IInboundApiRepository InboundApiRepository
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Welcome to ScadaBridge</h4>
<AuthorizeView>
<Authorized>
<span class="text-muted small">
@* CentralUI-024: claim type resolved via JwtTokenService. *@
Signed in as <strong>@context.User.GetDisplayName()</strong>
</span>
</Authorized>
</AuthorizeView>
</div>
<p class="text-muted">Central management console for the ScadaBridge SCADA system.</p>
@* KPI row *@
<div class="row g-3 mb-4">
<div class="col-lg-3 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<div class="fs-2 fw-bold">@(_loaded ? _siteCount.ToString() : "—")</div>
<div class="text-muted small">Sites configured</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<div class="fs-2 fw-bold">@(_loaded ? _dataConnectionCount.ToString() : "—")</div>
<div class="text-muted small">Data connections configured</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<div class="fs-2 fw-bold">@(_loaded ? _templateCount.ToString() : "—")</div>
<div class="text-muted small">Templates</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<div class="fs-2 fw-bold">@(_loaded ? _apiKeyCount.ToString() : "—")</div>
<div class="text-muted small">API keys</div>
</div>
</div>
</div>
</div>
@* Quick actions *@
<h6 class="text-muted text-uppercase small mb-2">Quick actions</h6>
<div class="row g-3">
<div class="col-lg-4 col-md-6 col-12">
<a class="card h-100 text-decoration-none text-reset" href="/monitoring/health">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="mb-1">Health Dashboard</h6>
<span class="text-muted">&rarr;</span>
</div>
<p class="text-muted small mb-0">Live cluster, data connection, and queue health per site.</p>
</div>
</a>
</div>
<div class="col-lg-4 col-md-6 col-12">
<a class="card h-100 text-decoration-none text-reset" href="/audit/configuration">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="mb-1">Configuration Audit Log</h6>
<span class="text-muted">&rarr;</span>
</div>
<p class="text-muted small mb-0">Browse changes to configuration and deployments.</p>
</div>
</a>
</div>
<div class="col-lg-4 col-md-6 col-12">
<a class="card h-100 text-decoration-none text-reset" href="/design/templates">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="mb-1">Templates</h6>
<span class="text-muted">&rarr;</span>
</div>
<p class="text-muted small mb-0">Design templates, shared scripts, and external systems.</p>
</div>
</a>
</div>
</div>
</div>
@code {
private bool _loaded;
private int _siteCount;
private int _dataConnectionCount;
private int _templateCount;
private int _apiKeyCount;
protected override async Task OnInitializedAsync()
{
try
{
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count;
}
catch
{
// Non-fatal — leave counts at zero with the placeholder rendering.
}
_loaded = true;
}
}
@@ -0,0 +1,94 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">New Area</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (RequireSitePicker)
{
<div class="mb-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_siteId">
<option value="0">(Select a site)</option>
@foreach (var opt in SiteOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label small">Parent area</label>
<select class="form-select form-select-sm" @bind="_parentAreaId">
<option value="0">(Site root)</option>
@foreach (var opt in ParentOptions.Where(o => SelectedSiteMatches(o)))
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
</div>
}
else
{
<div class="text-muted small mb-2">
@ContextLabel
</div>
}
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" placeholder="Area name" @bind="_name" />
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public bool RequireSitePicker { get; set; }
[Parameter] public string ContextLabel { get; set; } = string.Empty;
[Parameter] public int? SiteId { get; set; }
[Parameter] public int? ParentAreaId { get; set; }
[Parameter] public IEnumerable<(int Id, string Label)> SiteOptions { get; set; } = Array.Empty<(int, string)>();
[Parameter] public IEnumerable<(int Id, string Label, int SiteId)> ParentOptions { get; set; } = Array.Empty<(int, string, int)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int SiteId, int? ParentAreaId, string Name)> OnSubmit { get; set; }
private bool _wasVisible;
private string _name = string.Empty;
private int _siteId;
private int _parentAreaId;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_name = string.Empty;
_siteId = SiteId ?? 0;
_parentAreaId = ParentAreaId ?? 0;
}
_wasVisible = IsVisible;
}
private bool SelectedSiteMatches((int Id, string Label, int SiteId) opt) =>
_siteId == 0 || opt.SiteId == _siteId;
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
private async Task Submit()
{
var effectiveSite = RequireSitePicker ? _siteId : (SiteId ?? 0);
var effectiveParent = RequireSitePicker
? (_parentAreaId == 0 ? (int?)null : _parentAreaId)
: ParentAreaId;
await OnSubmit.InvokeAsync((effectiveSite, effectiveParent, _name.Trim()));
}
}
@@ -0,0 +1,615 @@
@page "/deployment/debug-view"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming
@using ZB.MOM.WW.ScadaBridge.Commons.Types
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.Communication
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject DebugStreamService DebugStreamService
@inject IJSRuntime JS
@implements IDisposable
<div class="container-fluid mt-3">
<h4 class="mb-3">Debug View</h4>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
@* Status strip — connection state, instance, last snapshot. *@
<div class="alert alert-light py-2 mb-3 d-flex justify-content-between align-items-center small flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>
@if (_connected)
{
var inst = _siteInstances.FirstOrDefault(i => i.Id == _selectedInstanceId);
@(inst?.UniqueName ?? "Connected")
}
else
{
<span class="text-muted">Not connected</span>
}
</strong>
@if (_connected)
{
<span class="badge bg-success" aria-label="Connection state: Live">
<span class="spinner-grow spinner-grow-sm me-1" style="width: 0.5rem; height: 0.5rem;" aria-hidden="true"></span>
Live
</span>
}
else
{
<span class="badge bg-secondary" aria-label="Connection state: Disconnected">Disconnected</span>
}
</div>
<div class="d-flex align-items-center gap-2">
@if (_snapshot != null)
{
<span class="text-muted">
Last snapshot: @_snapshot.SnapshotTimestamp.LocalDateTime.ToString("HH:mm:ss")
</span>
}
@if (_connected && _connectedFromStorage)
{
<button class="btn btn-outline-secondary btn-sm" @onclick="StartFresh"
aria-label="Clear persisted selection and disconnect">Start fresh</button>
}
</div>
</div>
<div class="row mb-3 g-2">
<div class="col-md-3">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_selectedSiteId" @bind:after="LoadInstancesForSite" disabled="@_connected">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name (@site.SiteIdentifier)</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Instance</label>
<select class="form-select form-select-sm" @bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged" disabled="@_connected">
<option value="0">Select instance...</option>
@foreach (var inst in _siteInstances)
{
<option value="@inst.Id">@inst.UniqueName (@inst.State)</option>
}
</select>
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
@if (!_connected)
{
<button class="btn btn-primary btn-sm" @onclick="Connect"
disabled="@(_selectedInstanceId == 0 || _selectedSiteId == 0 || _connecting)">
@if (_connecting) { <span class="spinner-border spinner-border-sm me-1" role="status" aria-label="Connecting"></span> }
Connect
</button>
}
else
{
<button class="btn btn-outline-danger btn-sm" @onclick="Disconnect">Disconnect</button>
}
</div>
</div>
@if (_connected && _snapshot != null)
{
<div class="row">
@* Attribute Values *@
<div class="col-md-7">
<div class="card">
<div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>Attribute Values</strong>
<small class="text-muted">@FilteredAttributeValues.Count latest (cap @MaxRows)</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 240px;"
placeholder="Filter by attribute…"
@bind="_attrFilter" @bind:event="oninput" aria-label="Filter attributes" />
<button class="btn btn-link btn-sm py-0" type="button"
@onclick="() => _attrScrollLocked = !_attrScrollLocked"
aria-pressed="@(_attrScrollLocked ? "true" : "false")"
aria-label="@(_attrScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
@(_attrScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
</button>
<button class="btn btn-outline-secondary btn-sm" type="button"
@onclick="ClearAttributes" aria-label="Clear attribute table">Clear</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Attribute</th>
<th>Value</th>
<th>Quality</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody aria-live="polite" aria-atomic="false">
@foreach (var av in FilteredAttributeValues)
{
<tr>
<td class="small">@av.AttributeName</td>
<td class="small font-monospace"><strong>@ValueFormatter.FormatDisplayValue(av.Value)</strong></td>
<td>
<span class="badge @GetQualityBadge(av.Quality)"
aria-label="@($"Quality: {av.Quality}")">@av.Quality</span>
</td>
<td class="small text-muted"
title="@av.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@av.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@* Alarm States *@
<div class="col-md-5">
<div class="card">
<div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>Alarm States</strong>
<small class="text-muted">@FilteredAlarmStates.Count latest (cap @MaxRows)</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 240px;"
placeholder="Filter by alarm…"
@bind="_alarmFilter" @bind:event="oninput" aria-label="Filter alarms" />
<button class="btn btn-link btn-sm py-0" type="button"
@onclick="() => _alarmScrollLocked = !_alarmScrollLocked"
aria-pressed="@(_alarmScrollLocked ? "true" : "false")"
aria-label="@(_alarmScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
@(_alarmScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
</button>
<button class="btn btn-outline-secondary btn-sm" type="button"
@onclick="ClearAlarms" aria-label="Clear alarm table">Clear</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Alarm</th>
<th>State</th>
<th>Level</th>
<th>Priority</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody aria-live="polite" aria-atomic="false">
@foreach (var alarm in FilteredAlarmStates)
{
<tr class="@GetAlarmRowClass(alarm.State)"
title="@(string.IsNullOrEmpty(alarm.Message) ? null : alarm.Message)">
<td class="small">
@alarm.AlarmName
@if (!string.IsNullOrEmpty(alarm.Message))
{
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
}
</td>
<td>
<span class="badge @GetAlarmStateBadge(alarm.State)"
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
</td>
<td>
@if (alarm.Level != AlarmLevel.None)
{
<span class="badge @GetAlarmLevelBadge(alarm.Level)"
aria-label="@($"Alarm level: {alarm.Level}")">@FormatLevel(alarm.Level)</span>
}
else
{
<span class="text-muted small">—</span>
}
</td>
<td class="small">@alarm.Priority</td>
<td class="small text-muted"
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
else if (_connected)
{
<LoadingSpinner IsLoading="true" Message="Waiting for snapshot..." />
}
}
</div>
@code {
private const int MaxRows = 200;
[SupplyParameterFromQuery] public int? SiteId { get; set; }
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
private List<Site> _sites = new();
private List<Instance> _siteInstances = new();
private int _selectedSiteId;
private int _selectedInstanceId;
private bool _loading = true;
private bool _connected;
private bool _connecting;
private bool _connectedFromStorage;
private DebugViewSnapshot? _snapshot;
// Keyed dictionaries hold the latest value per attribute/alarm; insertion order
// is preserved so we can trim the oldest when the count exceeds MaxRows.
private Dictionary<string, AttributeValueChanged> _attributeValues = new();
private Dictionary<string, AlarmStateChanged> _alarmStates = new();
// Filters and scroll-lock state per table.
private string _attrFilter = string.Empty;
private string _alarmFilter = string.Empty;
private bool _attrScrollLocked;
private bool _alarmScrollLocked;
private IReadOnlyList<AttributeValueChanged> FilteredAttributeValues =>
string.IsNullOrWhiteSpace(_attrFilter)
? _attributeValues.Values.OrderBy(a => a.AttributeName).ToList()
: _attributeValues.Values
.Where(a => a.AttributeName.Contains(_attrFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.AttributeName)
.ToList();
private IReadOnlyList<AlarmStateChanged> FilteredAlarmStates =>
string.IsNullOrWhiteSpace(_alarmFilter)
? _alarmStates.Values.OrderBy(a => a.AlarmName).ToList()
: _alarmStates.Values
.Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.AlarmName)
.ToList();
private DebugStreamSession? _session;
private ToastNotification _toast = default!;
private string? _initError;
// CentralUI-009: the stream callbacks (onEvent/onTerminated) run on an
// Akka/gRPC thread and capture `this` and `_toast`. Once the component is
// disposed, an in-flight callback must no-op rather than touch a disposed
// component (InvokeAsync would throw ObjectDisposedException) or a disposed
// ToastNotification.
private volatile bool _disposed;
protected override async Task OnInitializedAsync()
{
try
{
// Site scoping (CentralUI-002): a scoped Deployment user may only
// debug sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
}
catch (Exception ex)
{
_initError = $"Failed to load sites: {ex.Message}";
}
_loading = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
if (_initError != null)
{
_toast.ShowError(_initError);
_initError = null;
}
if (SiteId is > 0 && InstanceId is > 0)
{
_selectedSiteId = SiteId.Value;
await LoadInstancesForSite();
if (_siteInstances.Any(i => i.Id == InstanceId.Value))
{
_selectedInstanceId = InstanceId.Value;
await Connect();
}
else
{
_toast.ShowError("Requested instance is not available for debug streaming.");
}
StateHasChanged();
return;
}
var storedSiteId = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.siteId");
var storedInstanceId = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.instanceId");
if (!string.IsNullOrEmpty(storedSiteId) && int.TryParse(storedSiteId, out var siteId)
&& !string.IsNullOrEmpty(storedInstanceId) && int.TryParse(storedInstanceId, out var instanceId))
{
_selectedSiteId = siteId;
await LoadInstancesForSite();
_selectedInstanceId = instanceId;
_connectedFromStorage = true;
StateHasChanged();
await Connect();
// Auto-reconnect notice — the user didn't initiate this connection.
var inst = _siteInstances.FirstOrDefault(i => i.Id == instanceId);
_toast.ShowInfo(
$"Auto-reconnected to {inst?.UniqueName ?? "instance"} from previous session.",
autoDismissMs: 8000);
}
}
private async Task LoadInstancesForSite()
{
_siteInstances.Clear();
_selectedInstanceId = 0;
if (_selectedSiteId == 0) return;
// Site scoping (CentralUI-002): re-check the claim server-side — a query
// string or stale localStorage value could name a site outside the grant.
if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId))
{
_selectedSiteId = 0;
_toast.ShowError("You are not permitted to debug instances on that site.");
return;
}
try
{
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
.Where(i => i.State == InstanceState.Enabled)
.ToList();
}
catch (Exception ex)
{
_toast.ShowError($"Failed to load instances: {ex.Message}");
}
}
private void OnInstanceSelectionChanged()
{
// No-op; selection is tracked via _selectedInstanceId binding
}
private async Task Connect()
{
if (_selectedInstanceId == 0 || _selectedSiteId == 0) return;
_connecting = true;
try
{
var session = await DebugStreamService.StartStreamAsync(
_selectedInstanceId,
onEvent: HandleStreamEvent,
onTerminated: () =>
{
_connected = false;
_session = null;
// CentralUI-009: skip the toast/render if already disposed.
if (_disposed) return;
_ = SafeInvokeAsync(() =>
{
if (_disposed) return;
_toast.ShowError("Debug stream terminated (site disconnected).");
StateHasChanged();
});
});
_session = session;
// Populate initial state from snapshot
_attributeValues.Clear();
foreach (var av in session.InitialSnapshot.AttributeValues)
_attributeValues[av.AttributeName] = av;
_alarmStates.Clear();
foreach (var al in session.InitialSnapshot.AlarmStates)
_alarmStates[al.AlarmName] = al;
_snapshot = session.InitialSnapshot;
_connected = true;
// Persist selection to localStorage for auto-reconnect on refresh
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.siteId", _selectedSiteId.ToString());
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.instanceId", _selectedInstanceId.ToString());
var instance = _siteInstances.FirstOrDefault(i => i.Id == _selectedInstanceId);
_toast.ShowSuccess($"Streaming {instance?.UniqueName ?? "instance"}");
}
catch (Exception ex)
{
_toast.ShowError($"Connect failed: {ex.Message}");
}
_connecting = false;
}
private async Task Disconnect()
{
if (_session != null)
{
DebugStreamService.StopStream(_session.SessionId);
_session = null;
}
// Clear persisted selection — user explicitly disconnected
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.siteId");
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId");
_connected = false;
_connectedFromStorage = false;
_snapshot = null;
_attributeValues.Clear();
_alarmStates.Clear();
}
/// <summary>
/// Disconnect and forget the persisted selection. Surfaces in the status
/// strip whenever the page auto-reconnects from localStorage so the user
/// can opt out of the carry-over session.
/// </summary>
private async Task StartFresh()
{
await Disconnect();
_selectedSiteId = 0;
_selectedInstanceId = 0;
_siteInstances.Clear();
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
}
private void ClearAttributes()
{
_attributeValues.Clear();
}
private void ClearAlarms()
{
_alarmStates.Clear();
}
/// <summary>
/// Handles one debug-stream event. The callback is invoked on an Akka/gRPC
/// thread, but <see cref="_attributeValues"/>/<see cref="_alarmStates"/> are
/// <see cref="Dictionary{TKey,TValue}"/> instances also enumerated by the
/// render thread via <see cref="FilteredAttributeValues"/>/
/// <see cref="FilteredAlarmStates"/>. <c>Dictionary</c> is not thread-safe
/// (CentralUI-021): a write racing an enumeration can throw or corrupt the
/// buckets. The mutation (<see cref="UpsertWithCap"/>) is therefore
/// marshalled onto the renderer's dispatcher via <see cref="SafeInvokeAsync"/>
/// so every access to the dictionaries — read and write — happens on the
/// render thread.
/// </summary>
private void HandleStreamEvent(object evt)
{
// CentralUI-009: the component may have been disposed while this event
// was in flight on the Akka/gRPC thread.
if (_disposed) return;
_ = SafeInvokeAsync(() =>
{
if (_disposed) return;
switch (evt)
{
case AttributeValueChanged av:
UpsertWithCap(_attributeValues, av.AttributeName, av);
break;
case AlarmStateChanged al:
UpsertWithCap(_alarmStates, al.AlarmName, al);
break;
default:
return;
}
StateHasChanged();
});
}
/// <summary>
/// Replace or insert a value keyed by name, then trim the oldest entries
/// (queue-style) so the table size never exceeds MaxRows. Dictionary
/// preserves insertion order, so the first key is always the oldest.
/// <para>
/// Must be called on the render thread only (CentralUI-021) — see
/// <see cref="HandleStreamEvent"/>. The cap-trim loop is in the same
/// critical section as the upsert so the dictionary is never observed
/// over-capacity.
/// </para>
/// </summary>
private static void UpsertWithCap<T>(Dictionary<string, T> map, string key, T value)
{
map[key] = value;
while (map.Count > MaxRows)
{
var oldest = map.Keys.First();
map.Remove(oldest);
}
}
private static string GetQualityBadge(string quality) => quality switch
{
"Good" => "bg-success",
"Bad" => "bg-danger",
"Uncertain" => "bg-warning text-dark",
_ => "bg-secondary"
};
private static string GetAlarmStateBadge(AlarmState state) => state switch
{
AlarmState.Active => "bg-danger",
AlarmState.Normal => "bg-success",
_ => "bg-secondary"
};
private static string GetAlarmRowClass(AlarmState state) => state switch
{
AlarmState.Active => "table-danger",
_ => ""
};
/// <summary>
/// Severity-tinted badge class for HiLo alarm levels. The critical bands
/// (HighHigh / LowLow) get the danger class; warning bands get amber.
/// </summary>
private static string GetAlarmLevelBadge(AlarmLevel level) => level switch
{
AlarmLevel.HighHigh or AlarmLevel.LowLow => "bg-danger",
AlarmLevel.High or AlarmLevel.Low => "bg-warning text-dark",
_ => "bg-secondary"
};
private static string FormatLevel(AlarmLevel level) => level switch
{
AlarmLevel.HighHigh => "HiHi",
AlarmLevel.High => "Hi",
AlarmLevel.Low => "Lo",
AlarmLevel.LowLow => "LoLo",
_ => "—"
};
/// <summary>
/// Runs <paramref name="action"/> on the render thread, guarded against the
/// component being disposed mid-flight (CentralUI-009): <c>InvokeAsync</c>
/// throws <see cref="ObjectDisposedException"/> once the circuit is gone.
/// </summary>
private async Task SafeInvokeAsync(Action action)
{
if (_disposed) return;
try
{
await InvokeAsync(action);
}
catch (ObjectDisposedException)
{
// Component disposed between the guard and the dispatch — ignore.
}
}
public void Dispose()
{
// CentralUI-009: mark disposed first so any in-flight stream callback
// sees the flag and no-ops, then stop the stream synchronously.
_disposed = true;
if (_session != null)
{
DebugStreamService.StopStream(_session.SessionId);
}
}
}
@@ -0,0 +1,358 @@
@page "/deployment/deployments"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject IDeploymentManagerRepository DeploymentManagerRepository
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier DeploymentStatusNotifier
@implements IDisposable
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Deployment Status</h4>
<div class="d-flex gap-2 align-items-center">
<button class="btn btn-outline-secondary btn-sm" @onclick="ToggleAutoRefresh"
aria-label="@(_autoRefresh ? "Pause auto-refresh" : "Resume auto-refresh")">
@(_autoRefresh ? "⏸ Pause updates" : "▶ Resume updates")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
</div>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
@* Summary cards *@
<div class="row mb-3">
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-warning">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-warning">@_records.Count(r => r.Status == DeploymentStatus.Pending)</h4>
<small class="text-muted">Pending</small>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-info">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-info">@_records.Count(r => r.Status == DeploymentStatus.InProgress)</h4>
<small class="text-muted">In Progress</small>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-success">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-success">@_records.Count(r => r.Status == DeploymentStatus.Success)</h4>
<small class="text-muted">Successful</small>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-danger">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-danger">@_records.Count(r => r.Status == DeploymentStatus.Failed)</h4>
<small class="text-muted">Failed</small>
</div>
</div>
</div>
</div>
@if (_records.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-0">No deployments recorded.</p>
</div>
}
else
{
<table class="table table-sm table-striped table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Deployment</th>
<th>Instance</th>
<th>Status</th>
<th>Deployed By</th>
<th>Started</th>
<th>Completed</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var record in _pagedRecords)
{
var rowId = $"deploy-row-{record.DeploymentId}";
var errorCollapseId = $"deploy-err-{record.DeploymentId}";
var isFailed = record.Status == DeploymentStatus.Failed;
var idShort = record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)];
var revShort = record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)];
<tr id="@rowId" class="@GetRowClass(record.Status)">
<td>
<code class="small">@idShort@(string.IsNullOrEmpty(revShort) ? "" : $"@{revShort}")</code>
</td>
<td>@GetInstanceName(record.InstanceId)</td>
<td>
@if (isFailed)
{
<i class="bi bi-x-circle text-danger me-1" aria-hidden="true"></i>
}
<span class="badge @GetStatusBadge(record.Status)"
aria-label="@($"Deployment status: {record.Status}")">
@record.Status
@if (record.Status == DeploymentStatus.InProgress)
{
<span class="spinner-border spinner-border-sm ms-1" style="width: 0.7rem; height: 0.7rem;"
role="status" aria-label="Deployment in progress"></span>
}
</span>
</td>
<td class="small">@record.DeployedBy</td>
<td class="small">
<TimestampDisplay Value="@record.DeployedAt" />
</td>
<td class="small">
@if (record.CompletedAt.HasValue)
{
<TimestampDisplay Value="@record.CompletedAt.Value" />
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="small text-end">
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage))
{
<button class="btn btn-link btn-sm p-0" type="button"
@onclick="() => ToggleErrorExpansion(record.DeploymentId)"
aria-expanded="@(IsErrorExpanded(record.DeploymentId) ? "true" : "false")"
aria-controls="@errorCollapseId">
@(IsErrorExpanded(record.DeploymentId) ? "Hide error" : "View error")
</button>
}
</td>
</tr>
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage) && IsErrorExpanded(record.DeploymentId))
{
<tr id="@errorCollapseId" class="table-danger">
<td colspan="7">
<div class="small">
<strong>Error:</strong>
<pre class="mb-0 mt-1 small" style="white-space: pre-wrap; word-break: break-word;">@record.ErrorMessage</pre>
</div>
</td>
</tr>
}
}
</tbody>
</table>
}
@if (_totalPages > 1)
{
<nav>
<ul class="pagination pagination-sm justify-content-end">
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage - 1)">Previous</button>
</li>
@foreach (var page in ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared.PagerWindow.Build(_currentPage, _totalPages))
{
if (page == 0)
{
<li class="page-item disabled">
<span class="page-link">&hellip;</span>
</li>
}
else
{
var p = page;
<li class="page-item @(p == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(p)">@(p)</button>
</li>
}
}
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button>
</li>
</ul>
</nav>
}
}
</div>
@code {
private List<DeploymentRecord> _records = new();
private List<DeploymentRecord> _pagedRecords = new();
private Dictionary<int, string> _instanceNames = new();
private bool _loading = true;
private string? _errorMessage;
private bool _autoRefresh = true;
private readonly HashSet<string> _expandedErrors = new();
private int _currentPage = 1;
private int _totalPages;
private const int PageSize = 25;
// CentralUI-022: IDeploymentStatusNotifier is a process singleton that
// raises StatusChanged on the DeploymentManager service thread. Dispose()
// unsubscribes, but the notifier can read its subscriber list and begin
// invoking OnDeploymentStatusChanged just before this component is disposed.
// The handler then runs against a disposed component and InvokeAsync throws
// ObjectDisposedException as an unobserved fire-and-forget task exception.
// This flag (set first in Dispose()) makes a racing callback no-op, and the
// dispatch swallows the residual ObjectDisposedException — mirroring the
// DebugView (CentralUI-009) and ToastNotification (CentralUI-010) guards.
private volatile bool _disposed;
// CentralUI-006: deployment status updates are push-based, not polled.
// DeploymentManager raises IDeploymentStatusNotifier.StatusChanged on every
// deployment-record status write; this page subscribes to it and reloads,
// and Blazor Server pushes the re-render to the browser over its SignalR
// circuit — satisfying the design's "no polling required" requirement.
// The notifier event is raised on the DeploymentManager service thread, so
// the handler marshals onto the renderer via InvokeAsync.
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
DeploymentStatusNotifier.StatusChanged += OnDeploymentStatusChanged;
}
private void OnDeploymentStatusChanged(ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusChange change)
{
// CentralUI-022: a callback racing disposal must not touch the component.
if (_disposed || !_autoRefresh) return;
_ = DispatchReloadAsync();
}
/// <summary>
/// Reloads the deployment table on the renderer's dispatcher, guarded
/// against the component being disposed mid-flight (CentralUI-022):
/// <c>InvokeAsync</c> throws <see cref="ObjectDisposedException"/> once the
/// circuit is gone, and this handler runs fire-and-forget so that exception
/// would otherwise go unobserved on the DeploymentManager thread.
/// </summary>
private async Task DispatchReloadAsync()
{
if (_disposed) return;
try
{
await InvokeAsync(async () =>
{
if (_disposed) return;
await LoadDataAsync();
StateHasChanged();
});
}
catch (ObjectDisposedException)
{
// Component disposed between the guard and the dispatch — ignore.
}
}
private void ToggleAutoRefresh()
{
// When paused, incoming push notifications are ignored; "Refresh" still
// forces a manual reload. No timer is involved either way.
_autoRefresh = !_autoRefresh;
}
private bool IsErrorExpanded(string deploymentId) => _expandedErrors.Contains(deploymentId);
private void ToggleErrorExpansion(string deploymentId)
{
if (!_expandedErrors.Remove(deploymentId))
{
_expandedErrors.Add(deploymentId);
}
}
private async Task LoadDataAsync()
{
_loading = _records.Count == 0; // Only show loading on first load
_errorMessage = null;
try
{
// Build instance lookups first — site scoping (CentralUI-002) filters
// deployment records by the site of their instance.
var instances = await TemplateEngineRepository.GetAllInstancesAsync();
_instanceNames = instances.ToDictionary(i => i.Id, i => i.UniqueName);
var instanceSiteIds = instances.ToDictionary(i => i.Id, i => i.SiteId);
var systemWide = await SiteScope.IsSystemWideAsync();
var permittedSiteIds = systemWide
? null
: await SiteScope.PermittedSiteIdsAsync();
_records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync())
.Where(r => permittedSiteIds == null
|| (instanceSiteIds.TryGetValue(r.InstanceId, out var sid)
&& permittedSiteIds.Contains(sid)))
.OrderByDescending(r => r.DeployedAt)
.ToList();
_totalPages = Math.Max(1, (int)Math.Ceiling(_records.Count / (double)PageSize));
if (_currentPage > _totalPages) _currentPage = 1;
UpdatePage();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load deployments: {ex.Message}";
}
_loading = false;
}
private void GoToPage(int page)
{
if (page < 1 || page > _totalPages) return;
_currentPage = page;
UpdatePage();
}
private void UpdatePage()
{
_pagedRecords = _records
.Skip((_currentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
}
private string GetInstanceName(int instanceId) =>
_instanceNames.GetValueOrDefault(instanceId, $"#{instanceId}");
private static string GetStatusBadge(DeploymentStatus status) => status switch
{
DeploymentStatus.Pending => "bg-warning text-dark",
DeploymentStatus.InProgress => "bg-info text-dark",
DeploymentStatus.Success => "bg-success",
DeploymentStatus.Failed => "bg-danger",
_ => "bg-secondary"
};
private static string GetRowClass(DeploymentStatus status) => status switch
{
DeploymentStatus.Failed => "table-danger",
DeploymentStatus.InProgress => "table-info",
_ => ""
};
public void Dispose()
{
// CentralUI-022: set the guard first so a callback already in flight on
// the DeploymentManager thread no-ops, then unsubscribe so no further
// status change reaches this disposed component.
_disposed = true;
DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged;
}
}
@@ -0,0 +1,796 @@
@page "/deployment/instances/{Id:int}/configure"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject InstanceService InstanceService
@inject IFlatteningPipeline FlatteningPipeline
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back to Topology</button>
<h4 class="mb-0">Configure Instance</h4>
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log
pre-filtered to this instance. Instance is UI-only on the filter bar
(AuditEvent has no Instance column), so we use the ?instance= UI-text
seam — the filter bar's Instance free-text input is pre-populated. *@
@if (_instance != null)
{
<a class="btn btn-outline-secondary btn-sm ms-auto"
href="/audit/log?instance=@Uri.EscapeDataString(_instance.UniqueName)"
data-test="audit-link">
Recent audit activity
</a>
}
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else if (_instance != null)
{
@* Instance Identity *@
<div class="card mb-3">
<div class="card-body py-2">
<div class="row">
<div class="col-md-4">
<small class="text-muted">Instance</small>
<div><strong>@_instance.UniqueName</strong></div>
</div>
<div class="col-md-2">
<small class="text-muted">Template</small>
<div>@_templateName</div>
</div>
<div class="col-md-2">
<small class="text-muted">Site</small>
<div>@_siteName</div>
</div>
<div class="col-md-2">
<small class="text-muted">Status</small>
<div><span class="badge @GetStateBadge(_instance.State)">@_instance.State</span></div>
</div>
<div class="col-md-2">
<small class="text-muted">Area</small>
<div>@(_instance.AreaId.HasValue ? _areaName : "—")</div>
</div>
</div>
</div>
</div>
@* Connection Bindings *@
<div class="card mb-3">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<strong>Connection Bindings</strong>
@if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0)
{
<div class="d-flex align-items-center gap-1">
<select class="form-select form-select-sm" style="width: auto;" @bind="_bulkConnectionId">
<option value="0">Assign all to...</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name (@c.Protocol)</option>
}
</select>
<button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding"
disabled="@(_bulkConnectionId == 0)">Apply</button>
</div>
}
</div>
<div class="card-body p-0">
@if (_bindingDataSourceAttrs.Count == 0)
{
<p class="text-muted small p-3 mb-0">No data-sourced attributes in this template.</p>
}
else
{
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Attribute</th>
<th>Tag Path</th>
<th style="width: 280px;">Connection</th>
</tr>
</thead>
<tbody>
@foreach (var attr in _bindingDataSourceAttrs)
{
<tr>
<td class="small">@attr.Name</td>
<td class="small text-muted font-monospace">@attr.DataSourceReference</td>
<td>
<select class="form-select form-select-sm"
value="@GetBindingConnectionId(attr.Name)"
@onchange="(e) => OnBindingChanged(attr.Name, e)">
<option value="0">— none —</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
<div class="p-2">
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
</div>
}
</div>
</div>
@* Attribute Overrides *@
<div class="card mb-3">
<div class="card-header py-2">
<strong>Attribute Overrides</strong>
</div>
<div class="card-body p-0">
@if (_overrideAttrs.Count == 0)
{
<p class="text-muted small p-3 mb-0">No overridable (non-locked) attributes in this template.</p>
}
else
{
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Attribute</th>
<th>Type</th>
<th>Template Value</th>
<th style="width: 280px;">Override Value</th>
</tr>
</thead>
<tbody>
@foreach (var attr in _overrideAttrs)
{
<tr>
<td class="small">@attr.Name</td>
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
<td class="small text-muted">@(attr.Value ?? "—")</td>
<td>
<input type="text" class="form-control form-control-sm"
value="@GetOverrideValue(attr.Name)"
@onchange="(e) => OnOverrideChanged(attr.Name, e)" />
</td>
</tr>
}
</tbody>
</table>
<div class="p-2">
<button class="btn btn-success btn-sm" @onclick="SaveOverrides" disabled="@_saving">Save Overrides</button>
</div>
}
</div>
</div>
@* Alarm Overrides *@
<div class="card mb-3">
<div class="card-header py-2">
<strong>Alarm Overrides</strong>
<small class="text-muted ms-2">
Click <em>Edit</em> to override an alarm's trigger configuration or priority.
HiLo overrides merge into the inherited setpoints; other trigger types replace the whole config.
</small>
</div>
<div class="card-body p-0">
@if (_overridableAlarms.Count == 0)
{
<p class="text-muted small p-3 mb-0">No overridable (non-locked) alarms on this template.</p>
}
else
{
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Alarm</th>
<th style="width: 110px;">Trigger</th>
<th>Inherited Config</th>
<th style="width: 280px;">Override</th>
<th style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var alarm in _overridableAlarms)
{
<tr>
<td class="small">@alarm.Name</td>
<td>
<span class="badge bg-light text-dark border">@alarm.TriggerType</span>
</td>
<td class="small text-muted text-truncate font-monospace" style="max-width: 280px;"
title="@alarm.TriggerConfiguration">
@(alarm.TriggerConfiguration ?? "—")
</td>
<td class="small">
@if (HasOverride(alarm.Name))
{
<span class="badge bg-warning text-dark me-1" title="Override is set">●</span>
<span class="text-muted">@OverrideSummary(alarm.Name)</span>
}
else
{
<span class="text-muted fst-italic">inherited</span>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm me-1"
@onclick="() => BeginEditOverride(alarm)"
disabled="@_saving">Edit</button>
@if (HasOverride(alarm.Name))
{
<button class="btn btn-outline-danger btn-sm"
@onclick="() => ClearAlarmOverride(alarm.Name)"
disabled="@_saving">Clear</button>
}
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
@* Override edit modal *@
@if (_editingAlarm != null)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-dialog-scrollable modal-lg">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">
Edit override: @_editingAlarm.Name
<span class="badge bg-light text-dark border ms-1">@_editingAlarm.TriggerType</span>
</h6>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelEditOverride"></button>
</div>
<div class="modal-body">
<div class="mb-3 small">
<div class="text-muted text-uppercase fw-semibold mb-1">Inherited from template</div>
<code class="d-block bg-light p-2 rounded text-break">@(_editingAlarm.TriggerConfiguration ?? "(none)")</code>
</div>
<div class="mb-3">
<div class="text-muted text-uppercase small fw-semibold mb-1">Configuration</div>
<AlarmTriggerEditor TriggerType="@_editingAlarm.TriggerType"
Value="@_editingOverrideValue"
ValueChanged="@(v => _editingOverrideValue = v)"
AvailableAttributes="@_editingAvailableAttributes"
FallbackPriority="@_editingAlarm.PriorityLevel" />
</div>
<div class="row g-2">
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Priority override
</label>
<input type="number" min="0" max="1000" class="form-control form-control-sm"
placeholder="@_editingAlarm.PriorityLevel"
@bind="_editingPriorityText" @bind:event="oninput" />
</div>
</div>
@if (_editingError != null)
{
<div class="alert alert-danger small mt-2 mb-0">@_editingError</div>
}
</div>
<div class="modal-footer justify-content-between">
<div>
@if (HasOverride(_editingAlarm.Name))
{
<button class="btn btn-outline-danger btn-sm"
@onclick="() => ClearFromModal()"
disabled="@_saving">Clear Override</button>
}
</div>
<div>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelEditOverride">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
</div>
</div>
</div>
</div>
</div>
}
@* Area Assignment *@
<div class="card mb-3">
<div class="card-header py-2">
<strong>Area Assignment</strong>
</div>
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" style="width: auto;" @bind="_reassignAreaId">
<option value="0">No area</option>
@foreach (var a in _siteAreas)
{
<option value="@a.Id">@a.Name</option>
}
</select>
<button class="btn btn-outline-primary btn-sm" @onclick="ReassignArea" disabled="@_saving">Set Area</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int Id { get; set; }
private Instance? _instance;
private string _templateName = "";
private string _siteName = "";
private string _areaName = "";
private bool _loading = true;
private bool _saving;
private string? _errorMessage;
private ToastNotification _toast = default!;
// Bindings
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
private List<DataConnection> _siteConnections = new();
private Dictionary<string, int> _bindingSelections = new();
private int _bulkConnectionId;
// Overrides
private List<TemplateAttribute> _overrideAttrs = new();
private Dictionary<string, string?> _overrideValues = new();
// Alarm overrides — read-only state pulled from the repo. The edit modal
// is the only mutation path (one alarm at a time).
private List<TemplateAlarm> _overridableAlarms = new();
private Dictionary<string, InstanceAlarmOverride> _existingAlarmOverrides = new();
// Override edit modal state — non-null while the modal is open.
private TemplateAlarm? _editingAlarm;
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
private string? _editingInheritedValue; // the inherited config snapshot we diff against on save
private string? _editingPriorityText;
private string? _editingError;
private IReadOnlyList<AlarmAttributeChoice> _editingAvailableAttributes = Array.Empty<AlarmAttributeChoice>();
// Cached flattened attribute list (direct + inherited + composed members,
// path-qualified canonical names). Populated once after the instance loads
// and fed to the alarm trigger editor so composed-member paths like
// "AlarmSensor.SensorReading" resolve in the picker.
private IReadOnlyList<AlarmAttributeChoice> _flattenedAttributes = Array.Empty<AlarmAttributeChoice>();
// Area
private List<Area> _siteAreas = new();
private int _reassignAreaId;
protected override async Task OnInitializedAsync()
{
try
{
_instance = await TemplateEngineRepository.GetInstanceByIdAsync(Id);
if (_instance == null)
{
_errorMessage = $"Instance #{Id} not found.";
_loading = false;
return;
}
// Site scoping (CentralUI-002): a scoped Deployment user must not be
// able to configure or deploy an instance on a site outside their
// grant by navigating straight to its URL.
if (!await SiteScope.IsSiteAllowedAsync(_instance.SiteId))
{
_instance = null;
_errorMessage = "You are not permitted to manage instances on this site.";
_loading = false;
return;
}
// Identity
var template = await TemplateEngineRepository.GetTemplateByIdAsync(_instance.TemplateId);
_templateName = template?.Name ?? $"#{_instance.TemplateId}";
var sites = await SiteRepository.GetAllSitesAsync();
_siteName = sites.FirstOrDefault(s => s.Id == _instance.SiteId)?.Name ?? $"#{_instance.SiteId}";
// Areas
_siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_instance.SiteId)).ToList();
_reassignAreaId = _instance.AreaId ?? 0;
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
// Bindings
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(_instance.TemplateId);
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList();
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList();
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id);
foreach (var b in existingBindings)
_bindingSelections[b.AttributeName] = b.DataConnectionId;
// Overrides
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id);
foreach (var o in existingOverrides)
_overrideValues[o.AttributeName] = o.OverrideValue;
// Alarm overrides — load all non-locked template alarms and
// existing override rows. Pre-seed the dirty maps from existing
// values so the inputs render with what's currently saved.
var alarms = await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(_instance.TemplateId);
_overridableAlarms = alarms.Where(a => !a.IsLocked).ToList();
var alarmOverrides = await TemplateEngineRepository.GetAlarmOverridesByInstanceIdAsync(Id);
foreach (var o in alarmOverrides)
{
_existingAlarmOverrides[o.AlarmCanonicalName] = o;
}
_flattenedAttributes = await BuildFlattenedAttributesAsync();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load instance: {ex.Message}";
}
_loading = false;
}
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
// ── Bindings ────────────────────────────────────────────
private int GetBindingConnectionId(string attrName)
=> _bindingSelections.GetValueOrDefault(attrName, 0);
private void OnBindingChanged(string attrName, ChangeEventArgs e)
{
var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0;
if (val == 0) _bindingSelections.Remove(attrName);
else _bindingSelections[attrName] = val;
}
private void ApplyBulkBinding()
{
if (_bulkConnectionId == 0) return;
foreach (var attr in _bindingDataSourceAttrs)
_bindingSelections[attr.Name] = _bulkConnectionId;
}
private async Task SaveBindings()
{
_saving = true;
try
{
var bindings = _bindingSelections.Select(kv => new ConnectionBinding(kv.Key, kv.Value)).ToList();
var user = await GetCurrentUserAsync();
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
if (result.IsSuccess)
_toast.ShowSuccess($"Saved {bindings.Count} connection binding(s).");
else
_toast.ShowError($"Save failed: {result.Error}");
}
catch (Exception ex)
{
_toast.ShowError($"Save failed: {ex.Message}");
}
_saving = false;
}
// ── Overrides ───────────────────────────────────────────
private string? GetOverrideValue(string attrName)
=> _overrideValues.GetValueOrDefault(attrName);
private void OnOverrideChanged(string attrName, ChangeEventArgs e)
{
var val = e.Value?.ToString();
if (string.IsNullOrEmpty(val)) _overrideValues.Remove(attrName);
else _overrideValues[attrName] = val;
}
private async Task SaveOverrides()
{
_saving = true;
try
{
var user = await GetCurrentUserAsync();
foreach (var (attrName, value) in _overrideValues)
await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
_toast.ShowSuccess($"Saved {_overrideValues.Count} override(s).");
}
catch (Exception ex)
{
_toast.ShowError($"Save overrides failed: {ex.Message}");
}
_saving = false;
}
// ── Alarm overrides ─────────────────────────────────────
private bool HasOverride(string alarmName) =>
_existingAlarmOverrides.ContainsKey(alarmName);
/// <summary>
/// Human-readable summary of the currently-saved override. Lists the
/// HiLo keys that differ from the inherited config plus a priority chip.
/// Used by the row's "Override" column.
/// </summary>
private string OverrideSummary(string alarmName)
{
if (!_existingAlarmOverrides.TryGetValue(alarmName, out var ovr))
return "";
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(ovr.TriggerConfigurationOverride))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(ovr.TriggerConfigurationOverride);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object)
{
parts.AddRange(doc.RootElement.EnumerateObject().Select(p => p.Name));
}
}
catch (System.Text.Json.JsonException)
{
parts.Add("(invalid JSON)");
}
}
if (ovr.PriorityLevelOverride.HasValue)
parts.Add($"priority={ovr.PriorityLevelOverride.Value}");
return parts.Count == 0 ? "(empty)" : string.Join(", ", parts);
}
/// <summary>
/// Opens the override editor modal pre-populated with the merged
/// (inherited + existing override) config so the user sees the effective
/// state — not just the override delta.
/// </summary>
private void BeginEditOverride(TemplateAlarm alarm)
{
_editingAlarm = alarm;
_editingError = null;
_editingInheritedValue = alarm.TriggerConfiguration;
var existing = _existingAlarmOverrides.GetValueOrDefault(alarm.Name);
// HiLo: merge inherited + override so the editor shows the effective
// setpoints. Binary: pre-fill with the override if present, else the
// inherited config — same idea.
_editingOverrideValue = alarm.TriggerType == AlarmTriggerType.HiLo
? FlatteningService.MergeHiLoConfig(alarm.TriggerConfiguration, existing?.TriggerConfigurationOverride)
: (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration);
_editingPriorityText = existing?.PriorityLevelOverride?.ToString();
_editingAvailableAttributes = _flattenedAttributes;
}
private void CancelEditOverride()
{
_editingAlarm = null;
_editingError = null;
}
private async Task SaveOverrideFromModal()
{
if (_editingAlarm == null) return;
_saving = true;
try
{
int? priority = null;
if (!string.IsNullOrWhiteSpace(_editingPriorityText))
{
if (!int.TryParse(_editingPriorityText, out var p))
{
_editingError = "Priority must be an integer.";
_saving = false;
return;
}
priority = p;
}
// Compute the override JSON. For HiLo, diff against inherited so we
// store only the changed keys (matches the merge-on-flatten flow).
// For binary, whole-replace if the edited config differs from
// inherited.
string? overrideJson;
if (_editingAlarm.TriggerType == AlarmTriggerType.HiLo)
{
overrideJson = FlatteningService.DiffHiLoConfig(_editingInheritedValue, _editingOverrideValue);
}
else
{
overrideJson = _editingOverrideValue == _editingInheritedValue
? null
: _editingOverrideValue;
}
var user = await GetCurrentUserAsync();
var alarmName = _editingAlarm.Name;
// No diff + no priority → clear any existing override and close.
if (string.IsNullOrWhiteSpace(overrideJson) && !priority.HasValue)
{
if (_existingAlarmOverrides.ContainsKey(alarmName))
{
var del = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
if (!del.IsSuccess)
{
_editingError = del.Error;
_saving = false;
return;
}
_existingAlarmOverrides.Remove(alarmName);
_toast.ShowSuccess($"Cleared override on '{alarmName}'.");
}
else
{
_toast.ShowSuccess("No change.");
}
}
else
{
var result = await InstanceService.SetAlarmOverrideAsync(
Id, alarmName, overrideJson, priority, user);
if (!result.IsSuccess)
{
_editingError = result.Error;
_saving = false;
return;
}
_existingAlarmOverrides[alarmName] = result.Value!;
_toast.ShowSuccess($"Saved override on '{alarmName}'.");
}
_editingAlarm = null;
_editingError = null;
}
catch (Exception ex)
{
_editingError = ex.Message;
}
_saving = false;
}
private async Task ClearFromModal()
{
if (_editingAlarm == null) return;
var name = _editingAlarm.Name;
await ClearAlarmOverride(name);
_editingAlarm = null;
}
private async Task ClearAlarmOverride(string alarmName)
{
_saving = true;
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
if (result.IsSuccess)
{
_existingAlarmOverrides.Remove(alarmName);
_toast.ShowSuccess($"Cleared override on '{alarmName}'.");
}
else
{
_toast.ShowError($"Clear failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Clear failed: {ex.Message}");
}
_saving = false;
}
/// <summary>
/// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum
/// to the canonical SCADA type string the AlarmTriggerEditor compares
/// against (Boolean / Integer / Float / String / Object).
/// </summary>
private static string MapDataType(DataType dt) => dt switch
{
DataType.Boolean => "Boolean",
DataType.Int32 => "Integer",
DataType.Float => "Float",
DataType.Double => "Float",
DataType.String => "String",
DataType.DateTime => "String",
DataType.Binary => "Object",
_ => "Object"
};
/// <summary>
/// Same mapping for the string form emitted by <see cref="Commons.Types.Flattening.ResolvedAttribute.DataType"/>.
/// </summary>
private static string MapDataType(string dt) =>
Enum.TryParse<DataType>(dt, out var parsed) ? MapDataType(parsed) : dt;
/// <summary>
/// Builds the alarm picker choice list from the flattened configuration so
/// composed-member paths (e.g. <c>AlarmSensor.SensorReading</c>) and
/// inherited attributes appear alongside direct ones. Falls back to the
/// direct-only list if flattening fails for any reason.
/// </summary>
private async Task<IReadOnlyList<AlarmAttributeChoice>> BuildFlattenedAttributesAsync()
{
var fallback = (IReadOnlyList<AlarmAttributeChoice>)_overrideAttrs
.Select(a => new AlarmAttributeChoice(a.Name, MapDataType(a.DataType), "Direct"))
.ToList();
try
{
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id);
if (flat.IsFailure) return fallback;
return flat.Value.Configuration.Attributes
.Select(a => new AlarmAttributeChoice(
a.CanonicalName,
MapDataType(a.DataType),
a.Source switch
{
"Composed" => "Composed",
"Inherited" => "Inherited",
_ => "Direct" // Template / Override
}))
.ToList();
}
catch
{
return fallback;
}
}
// ── Area ────────────────────────────────────────────────
private async Task ReassignArea()
{
_saving = true;
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, user);
if (result.IsSuccess)
{
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
_toast.ShowSuccess("Area reassigned.");
}
else
_toast.ShowError($"Reassign failed: {result.Error}");
}
catch (Exception ex)
{
_toast.ShowError($"Reassign failed: {ex.Message}");
}
_saving = false;
}
private static string GetStateBadge(InstanceState state) => state switch
{
InstanceState.Enabled => "bg-success",
InstanceState.Disabled => "bg-secondary",
InstanceState.NotDeployed => "bg-light text-dark",
_ => "bg-secondary"
};
}
@@ -0,0 +1,164 @@
@page "/deployment/instances/create"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<a href="/deployment/topology" class="btn btn-outline-secondary btn-sm me-3">&larr; Back</a>
<h4 class="mb-0">Create Instance</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_createName" placeholder="e.g. Motor-1" />
</div>
<div class="mb-2">
<label class="form-label small">Template</label>
<select class="form-select form-select-sm" @bind="_createTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_createSiteId">
<option value="0">Select site...</option>
@foreach (var s in _sites)
{
<option value="@s.Id">@s.Name</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label small">Area</label>
<select class="form-select form-select-sm" @bind="_createAreaId">
<option value="0">No area</option>
@foreach (var a in _allAreas.Where(a => a.SiteId == _createSiteId))
{
<option value="@a.Id">@a.Name</option>
}
</select>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[SupplyParameterFromQuery] public int? SiteId { get; set; }
[SupplyParameterFromQuery] public int? AreaId { get; set; }
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
private bool _loading = true;
private string _createName = string.Empty;
private int _createTemplateId;
private int _createSiteId;
private int _createAreaId;
private string? _formError;
protected override async Task OnInitializedAsync()
{
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
// Site scoping (CentralUI-002): a scoped Deployment user may only
// create instances on sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
_allAreas.Clear();
foreach (var site in _sites)
{
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
_allAreas.AddRange(areas);
}
if (SiteId is int sid && _sites.Any(s => s.Id == sid))
{
_createSiteId = sid;
}
if (AreaId is int aid && _allAreas.Any(a => a.Id == aid && a.SiteId == _createSiteId))
{
_createAreaId = aid;
}
}
catch (Exception ex)
{
_formError = $"Failed to load data: {ex.Message}";
}
_loading = false;
}
private async Task CreateInstance()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Instance name is required."; return; }
if (_createTemplateId == 0) { _formError = "Select a template."; return; }
if (_createSiteId == 0) { _formError = "Select a site."; return; }
// Site scoping (CentralUI-002): re-check server-side before the mutating
// command, independent of what the site dropdown was populated with.
if (!await SiteScope.IsSiteAllowedAsync(_createSiteId))
{
_formError = "You are not permitted to create instances on the selected site.";
return;
}
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/deployment/topology");
}
else
{
_formError = result.Error;
}
}
catch (Exception ex)
{
_formError = $"Create failed: {ex.Message}";
}
}
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
}
@@ -0,0 +1,53 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move area '@AreaName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_targetParentId">
@foreach (var opt in ParentOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int AreaId { get; set; }
[Parameter] public string AreaName { get; set; } = string.Empty;
[Parameter] public int? CurrentParentId { get; set; }
[Parameter] public IEnumerable<(int? Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int AreaId, int? NewParentId)> OnSubmit { get; set; }
private bool _wasVisible;
private int? _targetParentId;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_targetParentId = CurrentParentId;
}
_wasVisible = IsVisible;
}
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
private async Task Submit() => await OnSubmit.InvokeAsync((AreaId, _targetParentId));
}
@@ -0,0 +1,53 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move '@InstanceName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_targetAreaId">
@foreach (var opt in AreaOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int InstanceId { get; set; }
[Parameter] public string InstanceName { get; set; } = string.Empty;
[Parameter] public int? CurrentAreaId { get; set; }
[Parameter] public IEnumerable<(int? Id, string Label)> AreaOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int InstanceId, int? NewAreaId)> OnSubmit { get; set; }
private bool _wasVisible;
private int? _targetAreaId;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_targetAreaId = CurrentAreaId;
}
_wasVisible = IsVisible;
}
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
private async Task Submit() => await OnSubmit.InvokeAsync((InstanceId, _targetAreaId));
}
@@ -0,0 +1,928 @@
@page "/deployment/topology"
@page "/deployment/instances"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject IDeploymentManagerRepository DeploymentManagerRepository
@inject DeploymentService DeploymentService
@inject AreaService AreaService
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime
@inject IDialogService Dialog
@implements IDisposable
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<CreateAreaDialog @bind-IsVisible="_showCreateAreaDialog"
RequireSitePicker="_createAreaRequireSitePicker"
ContextLabel="@_createAreaContextLabel"
SiteId="_createAreaSiteId"
ParentAreaId="_createAreaParentId"
SiteOptions="EnumerateSiteOptions()"
ParentOptions="EnumerateAreaOptionsForCreate()"
ErrorMessage="@_createAreaError"
OnSubmit="SubmitCreateArea" />
<MoveAreaDialog @bind-IsVisible="_showMoveAreaDialog"
AreaId="_moveAreaId"
AreaName="@_moveAreaName"
CurrentParentId="_moveAreaCurrentParentId"
ParentOptions="EnumerateAreaParentOptionsExcluding(_moveAreaId, _moveAreaSiteId)"
ErrorMessage="@_moveAreaError"
OnSubmit="SubmitMoveArea" />
<MoveInstanceDialog @bind-IsVisible="_showMoveInstanceDialog"
InstanceId="_moveInstanceId"
InstanceName="@_moveInstanceName"
CurrentAreaId="_moveInstanceCurrentAreaId"
AreaOptions="EnumerateAreaOptionsForSite(_moveInstanceSiteId)"
ErrorMessage="@_moveInstanceError"
OnSubmit="SubmitMoveInstance" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Topology</h4>
</div>
<div class="d-flex align-items-center mb-2 gap-2 flex-wrap">
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
placeholder="Search sites, areas, instances..."
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
<button class="btn btn-outline-secondary"
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
<button class="btn btn-outline-secondary" aria-label="Refresh topology" @onclick="LoadDataAsync">Refresh</button>
<button class="btn btn-outline-secondary" aria-label="Expand all areas" @onclick="() => _tree?.ExpandAll()">Expand</button>
<button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
</div>
<div class="form-check form-switch ms-2 mb-0">
<input type="checkbox" class="form-check-input" id="live-updates"
checked="@_liveUpdates" @onchange="OnLiveUpdatesToggled" />
<label class="form-check-label small" for="live-updates">Live updates</label>
</div>
</div>
<div class="alert alert-light py-2 mb-3 small">
@_allAreas.Count area(s) · @_allInstances.Count instance(s) across @_sites.Count site(s).
</div>
<div style="max-height: calc(100vh - 240px); overflow-y: auto; padding: 4px;">
<TreeView @ref="_tree" TItem="TopoNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Key"
StorageKey="topology-tree"
Selectable="true"
SelectedKey="_selectedKey"
SelectedKeyChanged="OnTreeNodeSelected">
<NodeContent Context="node">
@RenderNodeLabel(node)
</NodeContent>
<ContextMenu Context="node">
@RenderNodeContextMenu(node)
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
</EmptyContent>
</TreeView>
</div>
<DiffDialog @ref="_diffDialog" />
}
</div>
@code {
// ---- Data ----
private List<Instance> _allInstances = new();
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
private Dictionary<int, bool> _stalenessMap = new();
private bool _loading = true;
private string? _errorMessage;
private bool _actionInProgress;
private string _searchText = string.Empty;
private ToastNotification _toast = default!;
private DiffDialog _diffDialog = default!;
// ---- Live updates ----
private bool _liveUpdates = true;
private Timer? _liveUpdatesTimer;
private static readonly TimeSpan LiveUpdatesInterval = TimeSpan.FromSeconds(15);
private TreeView<TopoNode> _tree = default!;
private object? _selectedKey;
private const string SelectedKeyStorage = "topology-tree-selected";
// ---- Tree model ----
private enum TopoNodeKind { Site, Area, Instance }
private record TopoNode(
string Key,
TopoNodeKind Kind,
int EntityId,
int SiteId,
string Label,
Site? Site,
Area? Area,
Instance? Instance,
bool IsStale,
bool MatchesSearch,
List<TopoNode> Children);
private List<TopoNode> _treeRoots = new();
// ---- Inline rename ----
private string? _renamingKey;
private string _renameBuffer = string.Empty;
private string? _renameError;
// ---- Lifecycle ----
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
StartLiveUpdatesTimer();
}
private void StartLiveUpdatesTimer()
{
_liveUpdatesTimer?.Dispose();
if (!_liveUpdates) return;
_liveUpdatesTimer = new Timer(_ =>
{
InvokeAsync(async () =>
{
if (!_liveUpdates) return;
await LoadDataAsync();
StateHasChanged();
});
}, null, LiveUpdatesInterval, LiveUpdatesInterval);
}
private void OnLiveUpdatesToggled(ChangeEventArgs e)
{
_liveUpdates = e.Value is bool b && b;
if (_liveUpdates)
{
StartLiveUpdatesTimer();
}
else
{
_liveUpdatesTimer?.Dispose();
_liveUpdatesTimer = null;
}
}
public void Dispose()
{
_liveUpdatesTimer?.Dispose();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var stored = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", SelectedKeyStorage);
if (!string.IsNullOrEmpty(stored))
{
_selectedKey = stored;
StateHasChanged();
}
}
catch { /* no JS interop available (prerender, tests) — ignore */ }
}
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
// Site scoping (CentralUI-002): a scoped Deployment user only sees the
// sites — and therefore the areas/instances — they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
var permittedSiteIds = _sites.Select(s => s.Id).ToHashSet();
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
.Where(i => permittedSiteIds.Contains(i.SiteId))
.ToList();
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_allAreas.Clear();
foreach (var site in _sites)
{
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
_allAreas.AddRange(areas);
}
_stalenessMap.Clear();
foreach (var inst in _allInstances.Where(i => i.State != InstanceState.NotDeployed))
{
try
{
var comparison = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
_stalenessMap[inst.Id] = comparison.IsSuccess && comparison.Value.IsStale;
}
catch
{
_stalenessMap[inst.Id] = false;
}
}
BuildTree();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load topology: {ex.Message}";
}
_loading = false;
}
private void OnSearchChanged()
{
BuildTree();
}
private bool NodeMatchesSearch(string label)
{
if (string.IsNullOrWhiteSpace(_searchText)) return true;
return label.Contains(_searchText, StringComparison.OrdinalIgnoreCase);
}
private void BuildTree()
{
_treeRoots = _sites
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.Select(site =>
{
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
var siteInstances = _allInstances.Where(i => i.SiteId == site.Id).ToList();
var areaChildren = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
var rootInstances = siteInstances
.Where(i => i.AreaId == null)
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.Select(MakeInstanceNode)
.ToList();
var children = areaChildren.Concat(rootInstances).ToList();
return new TopoNode(
Key: $"s:{site.Id}",
Kind: TopoNodeKind.Site,
EntityId: site.Id,
SiteId: site.Id,
Label: site.Name,
Site: site,
Area: null,
Instance: null,
IsStale: false,
MatchesSearch: NodeMatchesSearch(site.Name),
Children: children);
})
.ToList();
}
private List<TopoNode> BuildAreaNodes(List<Area> allAreas, List<Instance> instances, int? parentId)
{
return allAreas
.Where(a => a.ParentAreaId == parentId)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.Select(area =>
{
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
var areaInstances = instances
.Where(i => i.AreaId == area.Id)
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.Select(MakeInstanceNode)
.ToList();
var children = childAreas.Concat(areaInstances).ToList();
return new TopoNode(
Key: $"a:{area.Id}",
Kind: TopoNodeKind.Area,
EntityId: area.Id,
SiteId: area.SiteId,
Label: area.Name,
Site: null,
Area: area,
Instance: null,
IsStale: false,
MatchesSearch: NodeMatchesSearch(area.Name),
Children: children);
})
.ToList();
}
private TopoNode MakeInstanceNode(Instance inst) => new(
Key: $"i:{inst.Id}",
Kind: TopoNodeKind.Instance,
EntityId: inst.Id,
SiteId: inst.SiteId,
Label: inst.UniqueName,
Site: null,
Area: null,
Instance: inst,
IsStale: _stalenessMap.GetValueOrDefault(inst.Id),
MatchesSearch: NodeMatchesSearch(inst.UniqueName),
Children: new List<TopoNode>());
// ---- Rendering ----
private RenderFragment RenderNodeLabel(TopoNode node) => __builder =>
{
var dim = !string.IsNullOrWhiteSpace(_searchText) && !SubtreeContainsMatch(node);
var labelStyle = dim ? "opacity: 0.4;" : null;
switch (node.Kind)
{
case TopoNodeKind.Site:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-building"></i></span>
<span class="tv-label fw-semibold" style="@labelStyle" title="@node.Label">@node.Label</span>
break;
case TopoNodeKind.Area:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-diagram-3"></i></span>
@if (_renamingKey == node.Key)
{
<input class="form-control form-control-sm d-inline-block" style="width: auto; max-width: 220px;"
aria-label="@($"Rename {node.Label}")"
@ref="_renameInput"
@bind="_renameBuffer"
@onkeydown="(e) => OnRenameKeyDown(e, node)"
@onblur="() => CancelRename()" />
@if (!string.IsNullOrEmpty(_renameError))
{
<span class="text-danger small ms-2">@_renameError</span>
}
}
else
{
<span class="tv-label" style="@labelStyle" title="@node.Label"
@ondblclick="() => BeginRename(node)">@node.Label</span>
}
break;
case TopoNodeKind.Instance:
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-box"></i></span>
<span class="tv-label" style="@labelStyle" title="@node.Label">@node.Label</span>
<span class="badge @GetStateBadge(node.Instance!.State) ms-1" style="@labelStyle">@node.Instance!.State</span>
@if (node.Instance!.State != InstanceState.NotDeployed)
{
@if (node.IsStale)
{
<span class="badge bg-warning text-dark ms-1" style="@labelStyle" aria-label="State: Stale">
<i class="bi bi-exclamation-triangle me-1"></i>Stale
</span>
}
else
{
<span class="badge bg-light text-dark ms-1" style="@labelStyle" aria-label="State: Current">Current</span>
}
}
break;
}
};
private static bool SubtreeContainsMatch(TopoNode node)
{
if (node.MatchesSearch) return true;
return node.Children.Any(SubtreeContainsMatch);
}
private RenderFragment RenderNodeContextMenu(TopoNode node) => __builder =>
{
switch (node.Kind)
{
case TopoNodeKind.Site:
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForSite(node.EntityId)">Add Area</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.EntityId}")'>
Create Instance here
</button>
break;
case TopoNodeKind.Area:
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForArea(node.SiteId, node.EntityId, node.Label)">Add Sub-area</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.SiteId}&areaId={node.EntityId}")'>
Create Instance here
</button>
<button class="dropdown-item" @onclick="() => OpenMoveAreaDialog(node)">Move to Area…</button>
<button class="dropdown-item" @onclick="() => BeginRename(node)">Rename…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteArea(node)" disabled="@_actionInProgress">Delete</button>
break;
case TopoNodeKind.Instance:
var inst = node.Instance!;
var isStale = node.IsStale;
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
@if (inst.State == InstanceState.Enabled)
{
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
disabled="@_actionInProgress">Disable</button>
}
else if (inst.State == InstanceState.Disabled)
{
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
disabled="@_actionInProgress">Enable</button>
}
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
Configure
</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/debug-view?siteId={node.SiteId}&instanceId={inst.Id}")'
disabled="@(inst.State != InstanceState.Enabled)">
Debug View
</button>
<button class="dropdown-item" @onclick="() => ShowDiff(inst)"
disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
<button class="dropdown-item" @onclick="() => OpenMoveInstanceDialog(node)">Move to Area…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
disabled="@_actionInProgress">Delete</button>
break;
}
};
private static string GetStateBadge(InstanceState state) => state switch
{
InstanceState.Enabled => "bg-success",
InstanceState.Disabled => "bg-secondary",
InstanceState.NotDeployed => "bg-light text-dark",
_ => "bg-secondary"
};
// ---- Selection ----
private async Task OnTreeNodeSelected(object? key)
{
_selectedKey = key;
try
{
await JSRuntime.InvokeVoidAsync("treeviewStorage.save", SelectedKeyStorage,
key?.ToString() ?? string.Empty);
}
catch { /* ignore */ }
}
// ---- Inline rename ----
private ElementReference _renameInput;
private void BeginRename(TopoNode node)
{
if (node.Kind != TopoNodeKind.Area) return;
_renamingKey = node.Key;
_renameBuffer = node.Label;
_renameError = null;
}
private void CancelRename()
{
_renamingKey = null;
_renameBuffer = string.Empty;
_renameError = null;
}
private async Task OnRenameKeyDown(KeyboardEventArgs e, TopoNode node)
{
if (e.Key == "Escape")
{
CancelRename();
}
else if (e.Key == "Enter")
{
await CommitRename(node);
}
}
private async Task CommitRename(TopoNode node)
{
if (string.IsNullOrWhiteSpace(_renameBuffer) || _renameBuffer.Trim() == node.Label)
{
CancelRename();
return;
}
var user = await GetCurrentUserAsync();
var result = await AreaService.UpdateAreaAsync(node.EntityId, _renameBuffer.Trim(), user);
if (result.IsSuccess)
{
CancelRename();
_toast.ShowSuccess($"Area renamed to '{result.Value.Name}'.");
await LoadDataAsync();
}
else
{
_renameError = result.Error;
}
}
// ---- Create-area dialog ----
private bool _showCreateAreaDialog;
private bool _createAreaRequireSitePicker;
private string _createAreaContextLabel = string.Empty;
private int? _createAreaSiteId;
private int? _createAreaParentId;
private string? _createAreaError;
private void OpenCreateAreaDialogRoot()
{
_createAreaRequireSitePicker = true;
_createAreaContextLabel = string.Empty;
_createAreaSiteId = null;
_createAreaParentId = null;
_createAreaError = null;
_showCreateAreaDialog = true;
}
private void OpenCreateAreaDialogForSite(int siteId)
{
var site = _sites.FirstOrDefault(s => s.Id == siteId);
_createAreaRequireSitePicker = false;
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} (root)";
_createAreaSiteId = siteId;
_createAreaParentId = null;
_createAreaError = null;
_showCreateAreaDialog = true;
}
private void OpenCreateAreaDialogForArea(int siteId, int parentAreaId, string parentLabel)
{
var site = _sites.FirstOrDefault(s => s.Id == siteId);
_createAreaRequireSitePicker = false;
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} → Parent: {parentLabel}";
_createAreaSiteId = siteId;
_createAreaParentId = parentAreaId;
_createAreaError = null;
_showCreateAreaDialog = true;
}
private async Task SubmitCreateArea((int SiteId, int? ParentAreaId, string Name) req)
{
_createAreaError = null;
if (req.SiteId == 0) { _createAreaError = "Select a site."; return; }
if (string.IsNullOrWhiteSpace(req.Name)) { _createAreaError = "Area name is required."; return; }
var user = await GetCurrentUserAsync();
var result = await AreaService.CreateAreaAsync(req.Name, req.SiteId, req.ParentAreaId, user);
if (result.IsSuccess)
{
_showCreateAreaDialog = false;
_toast.ShowSuccess($"Area '{result.Value.Name}' created.");
await LoadDataAsync();
}
else
{
_createAreaError = result.Error;
}
}
// ---- Move-area dialog ----
private bool _showMoveAreaDialog;
private int _moveAreaId;
private string _moveAreaName = string.Empty;
private int _moveAreaSiteId;
private int? _moveAreaCurrentParentId;
private string? _moveAreaError;
private void OpenMoveAreaDialog(TopoNode node)
{
_moveAreaId = node.EntityId;
_moveAreaName = node.Label;
_moveAreaSiteId = node.SiteId;
_moveAreaCurrentParentId = node.Area?.ParentAreaId;
_moveAreaError = null;
_showMoveAreaDialog = true;
}
private async Task SubmitMoveArea((int AreaId, int? NewParentId) req)
{
_moveAreaError = null;
var user = await GetCurrentUserAsync();
var result = await AreaService.MoveAreaAsync(req.AreaId, req.NewParentId, user);
if (result.IsSuccess)
{
_showMoveAreaDialog = false;
_toast.ShowSuccess($"Area '{_moveAreaName}' moved.");
await LoadDataAsync();
}
else
{
_moveAreaError = result.Error;
}
}
// ---- Move-instance dialog ----
private bool _showMoveInstanceDialog;
private int _moveInstanceId;
private string _moveInstanceName = string.Empty;
private int _moveInstanceSiteId;
private int? _moveInstanceCurrentAreaId;
private string? _moveInstanceError;
private void OpenMoveInstanceDialog(TopoNode node)
{
var inst = node.Instance!;
_moveInstanceId = inst.Id;
_moveInstanceName = inst.UniqueName;
_moveInstanceSiteId = inst.SiteId;
_moveInstanceCurrentAreaId = inst.AreaId;
_moveInstanceError = null;
_showMoveInstanceDialog = true;
}
private async Task SubmitMoveInstance((int InstanceId, int? NewAreaId) req)
{
_moveInstanceError = null;
var user = await GetCurrentUserAsync();
var result = await InstanceService.AssignToAreaAsync(req.InstanceId, req.NewAreaId, user);
if (result.IsSuccess)
{
_showMoveInstanceDialog = false;
_toast.ShowSuccess($"Instance '{_moveInstanceName}' moved.");
await LoadDataAsync();
}
else
{
_moveInstanceError = result.Error;
}
}
// ---- Area & instance deletion ----
private async Task DeleteArea(TopoNode node)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Area",
$"Delete area '{node.Label}'? This will fail if it has sub-areas or assigned instances.",
danger: true);
if (!confirmed) return;
_actionInProgress = true;
var user = await GetCurrentUserAsync();
var result = await AreaService.DeleteAreaAsync(node.EntityId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Area '{node.Label}' deleted.");
await LoadDataAsync();
}
else
{
_toast.ShowError(result.Error);
}
_actionInProgress = false;
}
private async Task DeleteInstance(Instance inst)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Instance",
$"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
danger: true);
if (!confirmed) return;
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deleted.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Delete failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
_actionInProgress = false;
}
// ---- Lifecycle actions ----
private async Task EnableInstance(Instance inst)
{
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' enabled.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Enable failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Enable failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DisableInstance(Instance inst)
{
var confirmed = await Dialog.ConfirmAsync(
"Disable Instance",
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
danger: true);
if (!confirmed) return;
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' disabled.");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Disable failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Disable failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DeployInstance(Instance inst)
{
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
await LoadDataAsync();
}
else
{
_toast.ShowError($"Deploy failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Deploy failed: {ex.Message}");
}
_actionInProgress = false;
}
// ---- Diff modal ----
private async Task ShowDiff(Instance inst)
{
DeploymentComparisonResult? diffResult = null;
string? diffError = null;
try
{
var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
if (result.IsSuccess)
{
diffResult = result.Value;
}
else
{
diffError = result.Error;
}
}
catch (Exception ex)
{
diffError = $"Failed to load diff: {ex.Message}";
}
RenderFragment body = builder =>
{
if (diffError != null)
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "alert alert-danger");
builder.AddContent(2, diffError);
builder.CloseElement();
}
else if (diffResult != null)
{
var stale = diffResult.IsStale;
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "mb-2");
builder.OpenElement(2, "span");
builder.AddAttribute(3, "class", stale ? "badge bg-warning text-dark" : "badge bg-success");
builder.AddContent(4, stale ? "Stale — changes pending" : "Current");
builder.CloseElement();
builder.OpenElement(5, "span");
builder.AddAttribute(6, "class", "text-muted small ms-2");
builder.AddContent(7,
$"Deployed: {diffResult.DeployedRevisionHash[..8]} | " +
$"Current: {diffResult.CurrentRevisionHash[..8]} | " +
$"Deployed at: {diffResult.DeployedAt.LocalDateTime:yyyy-MM-dd HH:mm}");
builder.CloseElement();
builder.CloseElement();
builder.OpenElement(8, "p");
builder.AddAttribute(9, "class", "text-muted small mb-0");
builder.AddContent(10, stale
? "The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes."
: "No differences between deployed and current configuration.");
builder.CloseElement();
}
};
await _diffDialog.ShowAsync($"Deployment Diff — {inst.UniqueName}", body);
}
// ---- Dropdown option helpers ----
private IEnumerable<(int Id, string Label)> EnumerateSiteOptions()
{
foreach (var s in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
yield return (s.Id, s.Name);
}
private IEnumerable<(int Id, string Label, int SiteId)> EnumerateAreaOptionsForCreate()
{
foreach (var a in WalkAllSiteAreas())
yield return a;
}
private IEnumerable<(int Id, string Label, int SiteId)> WalkAllSiteAreas()
{
foreach (var site in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
{
foreach (var entry in WalkSiteHierarchy(site.Id, parentId: null, depth: 0, excludeAreaId: null))
yield return entry;
}
}
private IEnumerable<(int? Id, string Label)> EnumerateAreaOptionsForSite(int siteId)
{
yield return ((int?)null, "(No area — site root)");
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: null))
yield return ((int?)entry.Id, entry.Label);
}
private IEnumerable<(int? Id, string Label)> EnumerateAreaParentOptionsExcluding(int areaId, int siteId)
{
yield return ((int?)null, "(Site root)");
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: areaId))
yield return ((int?)entry.Id, entry.Label);
}
private IEnumerable<(int Id, string Label, int SiteId)> WalkSiteHierarchy(int siteId, int? parentId, int depth, int? excludeAreaId)
{
var levelAreas = _allAreas
.Where(a => a.SiteId == siteId && a.ParentAreaId == parentId)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase);
foreach (var a in levelAreas)
{
if (excludeAreaId.HasValue && a.Id == excludeAreaId.Value) continue;
yield return (a.Id, new string(' ', depth * 2) + a.Name, siteId);
foreach (var sub in WalkSiteHierarchy(siteId, a.Id, depth + 1, excludeAreaId))
yield return sub;
}
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
}
@@ -0,0 +1,343 @@
@page "/design/api-methods/create"
@page "/design/api-methods/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IInboundApiRepository InboundApiRepository
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit API Method" : "Add API Method")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" disabled="@Id.HasValue" />
</div>
<div class="mb-3">
<label class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Approved API Keys</label>
@if (_allKeys.Count == 0)
{
<div class="form-text">
No API keys configured.
<a href="/admin/api-keys">Create one</a> to authorize callers for this method.
</div>
}
else
{
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
@foreach (var key in _allKeys)
{
var checkboxId = $"approved-key-{key.Id}";
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@checkboxId"
checked="@_selectedKeyIds.Contains(key.Id)"
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" />
<label class="form-check-label" for="@checkboxId">
@key.Name
@if (!key.IsEnabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
</label>
</div>
}
</div>
<div class="form-text">
Callers must present a checked key in the <code>X-API-Key</code> header to invoke this method.
</div>
}
</div>
<div class="mb-3">
<label class="form-label">Parameters</label>
<SchemaBuilder Mode="object"
Value="@_params"
ValueChanged="@(v => _params = v)" />
</div>
<div class="mb-3">
<label class="form-label">Return value</label>
<SchemaBuilder Mode="value"
Value="@_returns"
ValueChanged="@(v => _returns = v)" />
</div>
<div class="mb-3">
<label class="form-label">Script</label>
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
Language="csharp" Height="320px"
ScriptKind="ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-primary" @onclick="ToggleTestRunPanel">
@(_showTestRun ? "Hide Test Run" : "Test Run")
</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@if (_showTestRun)
{
<div class="card mt-3" id="test-run-panel">
<div class="card-header py-2">
<span class="fw-semibold">Test Run</span>
</div>
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
<strong>Heads up:</strong>
runs the script as typed (unsaved edits included) against the supplied
<code>Parameters</code>. <code>Route</code> calls throw — cross-site
routing needs a deployed site reachable over the cluster transport.
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label small">Parameter values</label>
<ParameterValueForm ParameterDefinitions="@_params"
Values="_paramValues"
ValuesChanged="@(v => _paramValues = v)" />
</div>
<div class="d-flex gap-2 align-items-center mb-3">
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
@if (_running)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Running…</span>
}
else
{
<span>Run</span>
}
</button>
@if (_runResult != null)
{
<span class="text-muted small">@_runResult.DurationMs ms</span>
}
</div>
@if (_runResult != null)
{
@if (_runResult.Success)
{
<div class="mb-3">
<label class="form-label small text-success mb-1">
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
</label>
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
</div>
}
else
{
<div class="mb-3">
<label class="form-label small text-danger mb-1">
<span class="badge bg-danger me-1">@ErrorKindLabel(_runResult.ErrorKind)</span>
</label>
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
@if (_runResult.Markers is { Count: > 0 })
{
<ul class="small text-danger mt-2 mb-0">
@foreach (var m in _runResult.Markers)
{
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
}
</ul>
}
</div>
}
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
{
<div class="mb-0">
<label class="form-label small mb-1">Console output</label>
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
</div>
}
}
</div>
</div>
}
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "", _script = "";
private int _timeoutSeconds = 30;
private string? _params, _returns;
private string? _formError;
private MonacoEditor? _editor;
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>();
private ApiMethod? _existing;
private List<ApiKey> _allKeys = new();
private HashSet<int> _selectedKeyIds = new();
private bool _showTestRun;
private bool _running;
private Dictionary<string, object?> _paramValues = new();
private ScriptAnalysis.SandboxRunResult? _runResult;
private CancellationTokenSource? _runCts;
protected override async Task OnInitializedAsync()
{
try
{
_allKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
}
catch (Exception ex) { _formError = ex.Message; }
if (Id.HasValue)
{
try
{
_existing = await InboundApiRepository.GetApiMethodByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
_script = _existing.Script;
_timeoutSeconds = _existing.TimeoutSeconds;
_params = _existing.ParameterDefinitions;
_returns = _existing.ReturnDefinition;
_selectedKeyIds = ParseApprovedKeyIds(_existing.ApprovedApiKeyIds);
}
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private static HashSet<int> ParseApprovedKeyIds(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return new HashSet<int>();
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
.Where(id => id > 0)
.ToHashSet();
}
private void ToggleKey(int keyId, bool isChecked)
{
if (isChecked) _selectedKeyIds.Add(keyId);
else _selectedKeyIds.Remove(keyId);
}
private string? SerializeApprovedKeyIds() =>
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_script))
{
_formError = "Name and script required.";
return;
}
try
{
var approvedKeyIds = SerializeApprovedKeyIds();
if (_existing != null)
{
_existing.Script = _script;
_existing.TimeoutSeconds = _timeoutSeconds;
_existing.ParameterDefinitions = _params?.Trim();
_existing.ReturnDefinition = _returns?.Trim();
_existing.ApprovedApiKeyIds = approvedKeyIds;
await InboundApiRepository.UpdateApiMethodAsync(_existing);
}
else
{
var m = new ApiMethod(_name.Trim(), _script)
{
TimeoutSeconds = _timeoutSeconds,
ParameterDefinitions = _params?.Trim(),
ReturnDefinition = _returns?.Trim(),
ApprovedApiKeyIds = approvedKeyIds
};
await InboundApiRepository.AddApiMethodAsync(m);
}
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
private async Task RunInSandboxAsync()
{
_runCts?.Cancel();
_runCts = new CancellationTokenSource();
_running = true;
_runResult = null;
StateHasChanged();
try
{
var jsonParams = _paramValues.ToDictionary(
kv => kv.Key,
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
var request = new ScriptAnalysis.SandboxRunRequest(
_script, jsonParams, TimeoutSeconds: _timeoutSeconds,
Kind: ScriptAnalysis.ScriptKind.InboundApi);
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
}
catch (OperationCanceledException) { /* superseded by next Run click */ }
catch (Exception ex)
{
_runResult = new ScriptAnalysis.SandboxRunResult(
Success: false,
ReturnValueJson: null,
ReturnTypeName: null,
ConsoleOutput: "",
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError,
DurationMs: 0,
Markers: null);
}
finally
{
_running = false;
StateHasChanged();
}
}
private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch
{
ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
_ => "Error"
};
}
@@ -0,0 +1,241 @@
@page "/design/connections/create"
@page "/design/connections/{Id:int}/edit"
@page "/design/data-connections/create"
@page "/design/data-connections/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
@using ZB.MOM.WW.ScadaBridge.Commons.Serialization
@using ZB.MOM.WW.ScadaBridge.Commons.Validators
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Forms
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Site</label>
@if (_siteLocked)
{
<input type="text"
class="form-control form-control-plaintext form-control-sm"
readonly
value="@_siteName" />
<div class="form-text">Site is locked after creation.</div>
}
else
{
<select class="form-select form-select-sm" @bind="_formSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
}
</div>
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<h6 class="text-muted mt-3">Primary endpoint</h6>
<OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryConfig"
IsLegacy="_primaryIsLegacy"
Errors="_primaryErrors" />
<h6 class="text-muted mt-3">
Backup endpoint
@if (!_showBackup)
{
<span class="badge bg-light text-muted border ms-2">Optional</span>
}
</h6>
@if (!_showBackup)
{
<div class="mb-3">
<button type="button" class="btn btn-outline-secondary btn-sm"
@onclick="EnableBackup">Add Backup Endpoint</button>
</div>
}
else
{
<OpcUaEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupConfig"
IsLegacy="_backupIsLegacy"
Errors="_backupErrors" />
<div class="mb-2">
<label class="form-label small">Failover Retry Count</label>
<input type="number" class="form-control form-control-sm" style="max-width: 120px;"
min="1" max="20" @bind="_formFailoverRetryCount" />
<div class="form-text">Retries before failing over to backup endpoint.</div>
</div>
<div class="mb-3">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="RemoveBackup">Remove Backup</button>
</div>
}
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
[SupplyParameterFromQuery] public int? SiteId { get; set; }
private bool _loading = true;
private DataConnection? _editingConnection;
private List<Site> _sites = new();
private int _formSiteId;
private string _siteName = string.Empty;
private bool _siteLocked;
private string _formName = string.Empty;
private OpcUaEndpointConfig _primaryConfig = new();
private OpcUaEndpointConfig _backupConfig = new();
private bool _primaryIsLegacy;
private bool _backupIsLegacy;
private bool _showBackup;
private int _formFailoverRetryCount = 3;
private ValidationResult? _primaryErrors;
private ValidationResult? _backupErrors;
private string? _formError;
protected override async Task OnInitializedAsync()
{
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
if (Id.HasValue)
{
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
if (_editingConnection != null)
{
_formSiteId = _editingConnection.SiteId;
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_siteLocked = true;
_formName = _editingConnection.Name;
(_primaryConfig, _primaryIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration))
{
(_backupConfig, _backupIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = _editingConnection.FailoverRetryCount;
}
}
}
else if (SiteId.HasValue)
{
var site = _sites.FirstOrDefault(s => s.Id == SiteId.Value);
if (site != null)
{
_formSiteId = site.Id;
_siteName = site.Name;
_siteLocked = true;
}
}
}
catch (Exception ex)
{
_formError = $"Failed to load: {ex.Message}";
}
finally
{
_loading = false;
}
}
private async Task SaveConnection()
{
_formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
_backupErrors = _showBackup
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
: null;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
{
_formError = "Fix the errors below before saving.";
return;
}
var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = "OpcUa";
_editingConnection.PrimaryConfiguration = primaryJson;
_editingConnection.BackupConfiguration = backupJson;
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId)
{
PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson,
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/connections");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void EnableBackup() => _showBackup = true;
private void RemoveBackup()
{
_showBackup = false;
_backupConfig = new OpcUaEndpointConfig();
_backupIsLegacy = false;
_formFailoverRetryCount = 3;
}
private void GoBack() => NavigationManager.NavigateTo("/design/connections");
}
@@ -0,0 +1,313 @@
@page "/design/connections"
@page "/design/data-connections"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Connections</h4>
<div class="d-flex gap-2">
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
data-bs-toggle="dropdown">
Bulk actions
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="() => _tree?.ExpandAll()">
Expand all
</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => _tree?.CollapseAll()">
Collapse all
</button>
</li>
</ul>
</div>
<button class="btn btn-primary btn-sm"
disabled="@(!HasSiteSelected)"
@onclick="OnAddConnectionClicked">+ Connection</button>
</div>
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="mb-3" style="max-width: 320px;">
<input type="text" class="form-control form-control-sm"
placeholder="Search sites or connections..."
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
</div>
@if (!string.IsNullOrWhiteSpace(_searchText) && _matchKeys.Count == 0 && _treeRoots.Count > 0)
{
<p class="text-muted small">No connections match the filter.</p>
}
<TreeView @ref="_tree" TItem="DcTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Key"
StorageKey="data-connections-tree"
Selectable="true"
SelectedKey="_selectedKey"
SelectedKeyChanged="OnTreeNodeSelected">
<NodeContent Context="node">
@{
var labelStyle = IsDimmed(node) ? "opacity: 0.4;" : "";
}
@if (node.Kind == DcNodeKind.Site)
{
<span class="tv-label fw-semibold" style="@labelStyle">@node.Label</span>
<span class="badge bg-secondary ms-1">@node.Children.Count</span>
}
else
{
<span class="tv-label" style="@labelStyle">@node.Label</span>
<span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
}
<span class="tv-meta">
<div class="dropdown dc-node-actions" @onclick:stopPropagation="true">
<button type="button"
class="btn btn-link btn-sm p-0 dc-kebab"
data-bs-toggle="dropdown"
aria-label="@($"More actions for {node.Label}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (node.Kind == DcNodeKind.Site)
{
<li>
<button class="dropdown-item"
@onclick="() => AddConnectionForSite(node.SiteId!.Value)">
Add Connection here
</button>
</li>
}
else
{
<li>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
Edit
</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteConnection(node.Connection!)">
Delete
</button>
</li>
}
</ul>
</div>
</span>
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == DcNodeKind.Site)
{
<button class="dropdown-item"
@onclick="() => AddConnectionForSite(node.SiteId!.Value)">
Add Connection here
</button>
}
else
{
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
Edit
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger"
@onclick="() => DeleteConnection(node.Connection!)">
Delete
</button>
}
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
</EmptyContent>
</TreeView>
<div class="text-muted small mt-2">
@_connections.Count connection(s) across @_treeRoots.Count site(s).
</div>
}
</div>
<style>
/* Kebab visible-on-hover for tree nodes; always visible at small sizes for touch. */
.dc-node-actions .dc-kebab {
opacity: 0;
line-height: 1;
padding: 0 0.25rem !important;
color: var(--bs-secondary-color);
}
.tv-row:hover .dc-node-actions .dc-kebab,
.dc-node-actions.show .dc-kebab,
.dc-node-actions .dc-kebab:focus {
opacity: 1;
}
@@media (max-width: 768px) {
.dc-node-actions .dc-kebab { opacity: 1; }
}
</style>
@code {
record DcTreeNode(string Key, string Label, DcNodeKind Kind, List<DcTreeNode> Children,
int? SiteId = null, DataConnection? Connection = null);
enum DcNodeKind { Site, DataConnection }
private List<DcTreeNode> _treeRoots = new();
private List<DataConnection> _connections = new();
private bool _loading = true;
private string? _errorMessage;
private TreeView<DcTreeNode>? _tree;
private object? _selectedKey;
private string _searchText = string.Empty;
private HashSet<string> _matchKeys = new();
private ToastNotification _toast = default!;
private bool HasSiteSelected => ResolveSelectedSiteId() != null;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
var sites = await SiteRepository.GetAllSitesAsync();
_connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
var connBySite = _connections.GroupBy(c => c.SiteId).ToDictionary(g => g.Key, g => g.ToList());
_treeRoots = sites.Select(site => new DcTreeNode(
Key: $"site-{site.Id}",
Label: site.Name,
Kind: DcNodeKind.Site,
Children: (connBySite.GetValueOrDefault(site.Id) ?? new())
.Select(c => new DcTreeNode(
Key: $"conn-{c.Id}",
Label: c.Name,
Kind: DcNodeKind.DataConnection,
Children: new(),
SiteId: c.SiteId,
Connection: c))
.ToList(),
SiteId: site.Id
)).ToList();
RebuildMatchKeys();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load data: {ex.Message}";
}
_loading = false;
}
private void OnTreeNodeSelected(object? key)
{
_selectedKey = key;
}
private int? ResolveSelectedSiteId()
{
if (_selectedKey is not string keyStr) return null;
foreach (var site in _treeRoots)
{
if (site.Key == keyStr) return site.SiteId;
foreach (var child in site.Children)
{
if (child.Key == keyStr) return site.SiteId;
}
}
return null;
}
private void OnAddConnectionClicked()
{
var sid = ResolveSelectedSiteId();
if (sid == null) return;
AddConnectionForSite(sid.Value);
}
private void AddConnectionForSite(int siteId)
{
NavigationManager.NavigateTo($"/design/connections/create?siteId={siteId}");
}
private void OnSearchChanged()
{
RebuildMatchKeys();
}
private void RebuildMatchKeys()
{
_matchKeys.Clear();
if (string.IsNullOrWhiteSpace(_searchText)) return;
var q = _searchText.Trim();
foreach (var root in _treeRoots)
{
SubtreeContainsMatch(root, q);
}
}
private bool SubtreeContainsMatch(DcTreeNode node, string query)
{
var selfMatch = node.Label.Contains(query, StringComparison.OrdinalIgnoreCase);
var childMatch = false;
foreach (var child in node.Children)
{
if (SubtreeContainsMatch(child, query)) childMatch = true;
}
if (selfMatch || childMatch) _matchKeys.Add(node.Key);
return selfMatch || childMatch;
}
private bool IsDimmed(DcTreeNode node)
{
if (string.IsNullOrWhiteSpace(_searchText)) return false;
return !_matchKeys.Contains(node.Key);
}
private async Task DeleteConnection(DataConnection conn)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Connection",
$"Delete data connection '{conn.Name}'?",
danger: true);
if (!confirmed) return;
try
{
await SiteRepository.DeleteDataConnectionAsync(conn.Id);
await SiteRepository.SaveChangesAsync();
_toast.ShowSuccess($"Connection '{conn.Name}' deleted.");
await LoadDataAsync();
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
}
@@ -0,0 +1,121 @@
@page "/design/db-connections/create"
@page "/design/db-connections/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IExternalSystemRepository ExternalSystemRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit Database Connection" : "Add Database Connection")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" />
</div>
<div class="mb-3">
<label class="form-label">Connection String</label>
<input type="text" class="form-control" @bind="_connectionString" />
<div class="form-text">Treat as sensitive — visible to admins only.</div>
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<input type="number" class="form-control" @bind="_maxRetries" min="0" />
</div>
<div class="mb-3">
<label class="form-label">Retry Delay (seconds)</label>
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "", _connectionString = "";
private int _maxRetries = 3;
private int _retryDelaySeconds = 5;
private string? _formError;
private DatabaseConnectionDefinition? _existing;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await ExternalSystemRepository.GetDatabaseConnectionByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
_connectionString = _existing.ConnectionString;
_maxRetries = _existing.MaxRetries;
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
}
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_connectionString))
{
_formError = "Name and connection string required.";
return;
}
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.ConnectionString = _connectionString.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_existing);
}
else
{
var dc = new DatabaseConnectionDefinition(_name.Trim(), _connectionString.Trim())
{
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
};
await ExternalSystemRepository.AddDatabaseConnectionAsync(dc);
}
await ExternalSystemRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}
@@ -0,0 +1,160 @@
@page "/design/external-systems/create"
@page "/design/external-systems/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IExternalSystemRepository ExternalSystemRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(Id.HasValue ? "Edit External System" : "Add External System")</h4>
@* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log
pre-filtered to this external system's outbound API events. Audit rows
record the target by external-system name, so we filter on Target. *@
@if (Id.HasValue && !string.IsNullOrWhiteSpace(_name))
{
<a class="btn btn-outline-secondary btn-sm"
href="/audit/log?target=@Uri.EscapeDataString(_name)"
data-test="audit-link">
Recent audit activity
</a>
}
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" />
</div>
<div class="mb-3">
<label class="form-label">Endpoint URL</label>
<input type="text" class="form-control" @bind="_endpointUrl" />
</div>
<div class="mb-3">
<label class="form-label">Auth Type</label>
<select class="form-select" @bind="_authType">
<option>ApiKey</option>
<option>BasicAuth</option>
<option>None</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Auth Config (JSON)</label>
<input type="text" class="form-control" @bind="_authConfig"
placeholder="@_authConfigPlaceholder" />
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<input type="number" class="form-control" @bind="_maxRetries" min="0" />
</div>
<div class="mb-3">
<label class="form-label">Retry Delay (seconds)</label>
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "", _endpointUrl = "", _authType = "ApiKey";
private string? _authConfig;
private int _maxRetries = 3;
private int _retryDelaySeconds = 5;
private string? _formError;
private ExternalSystemDefinition? _existing;
// Per-AuthType placeholder that hints at the expected JSON shape for AuthConfig.
private string _authConfigPlaceholder => _authType switch
{
"ApiKey" => "{\"key\":\"xyz\"}",
"BasicAuth" => "{\"username\":\"u\",\"password\":\"p\"}",
_ => "{}"
};
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await ExternalSystemRepository.GetExternalSystemByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
_endpointUrl = _existing.EndpointUrl;
_authType = _existing.AuthType;
_authConfig = _existing.AuthConfiguration;
_maxRetries = _existing.MaxRetries;
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
}
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_endpointUrl))
{
_formError = "Name and URL required.";
return;
}
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.EndpointUrl = _endpointUrl.Trim();
_existing.AuthType = _authType;
_existing.AuthConfiguration = _authConfig?.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await ExternalSystemRepository.UpdateExternalSystemAsync(_existing);
}
else
{
var es = new ExternalSystemDefinition(_name.Trim(), _endpointUrl.Trim(), _authType)
{
AuthConfiguration = _authConfig?.Trim(),
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
};
await ExternalSystemRepository.AddExternalSystemAsync(es);
}
await ExternalSystemRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}
@@ -0,0 +1,343 @@
@page "/design/external-systems"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IExternalSystemRepository ExternalSystemRepository
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Integration Definitions</h4>
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(_tab == "extsys" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "extsys" ? "true" : "false")"
aria-controls="int-tab-extsys"
@onclick='() => _tab = "extsys"'>
External Systems <span class="badge bg-secondary">@_externalSystems.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_tab == "dbconn" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "dbconn" ? "true" : "false")"
aria-controls="int-tab-dbconn"
@onclick='() => _tab = "dbconn"'>
Database Connections <span class="badge bg-secondary">@_dbConnections.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_tab == "inbound" ? "active" : "")"
role="tab"
aria-selected="@(_tab == "inbound" ? "true" : "false")"
aria-controls="int-tab-inbound"
@onclick='() => _tab = "inbound"'>
Inbound API Methods <span class="badge bg-secondary">@_apiMethods.Count</span>
</button>
</li>
</ul>
@if (_tab == "extsys")
{
<div role="tabpanel" id="int-tab-extsys">@RenderExternalSystems()</div>
}
else if (_tab == "dbconn")
{
<div role="tabpanel" id="int-tab-dbconn">@RenderDbConnections()</div>
}
else if (_tab == "inbound")
{
<div role="tabpanel" id="int-tab-inbound">@RenderInboundApiMethods()</div>
}
}
</div>
@code {
private bool _loading = true;
private string? _errorMessage;
private string _tab = "extsys";
// External Systems
private List<ExternalSystemDefinition> _externalSystems = new();
private string _extsysSearch = "";
private IEnumerable<ExternalSystemDefinition> FilteredExternalSystems =>
string.IsNullOrWhiteSpace(_extsysSearch)
? _externalSystems
: _externalSystems.Where(es => es.Name?.Contains(_extsysSearch, StringComparison.OrdinalIgnoreCase) ?? false);
// Database Connections
private List<DatabaseConnectionDefinition> _dbConnections = new();
private string _dbConnSearch = "";
private IEnumerable<DatabaseConnectionDefinition> FilteredDbConnections =>
string.IsNullOrWhiteSpace(_dbConnSearch)
? _dbConnections
: _dbConnections.Where(dc => dc.Name?.Contains(_dbConnSearch, StringComparison.OrdinalIgnoreCase) ?? false);
// Inbound API Methods
private List<ApiMethod> _apiMethods = new();
private string _apiMethodSearch = "";
private IEnumerable<ApiMethod> FilteredApiMethods =>
string.IsNullOrWhiteSpace(_apiMethodSearch)
? _apiMethods
: _apiMethods.Where(m => m.Name?.Contains(_apiMethodSearch, StringComparison.OrdinalIgnoreCase) ?? false);
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
await LoadAllAsync();
}
private async Task LoadAllAsync()
{
_loading = true;
try
{
_externalSystems = (await ExternalSystemRepository.GetAllExternalSystemsAsync()).ToList();
_dbConnections = (await ExternalSystemRepository.GetAllDatabaseConnectionsAsync()).ToList();
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
}
catch (Exception ex) { _errorMessage = ex.Message; }
_loading = false;
}
// ==== External Systems ====
private RenderFragment RenderExternalSystems() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">External Systems</h5>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>Add External System</button>
</div>
@if (_externalSystems.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No external systems configured.</p>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>
Add your first external system
</button>
</div>
}
else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name…"
@bind="_extsysSearch" @bind:event="oninput" />
</div>
@if (!FilteredExternalSystems.Any())
{
<p class="text-muted small">No external systems match the filter.</p>
}
<div class="row g-3">
@foreach (var es in FilteredExternalSystems)
{
<div class="col-lg-6 col-12" @key="es.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">@es.Name</h5>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/external-systems/{es.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {es.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item text-danger" @onclick="() => DeleteExtSys(es)">Delete</button></li>
</ul>
</div>
</div>
</div>
<p class="small text-muted text-truncate mb-1" title="@es.EndpointUrl">@es.EndpointUrl</p>
<div>
<span class="badge bg-secondary me-1">@es.AuthType</span>
<span class="badge bg-light text-dark me-1">Max @es.MaxRetries retries</span>
<span class="badge bg-light text-dark">Delay @es.RetryDelay.TotalSeconds s</span>
</div>
</div>
</div>
</div>
}
</div>
}
};
private async Task DeleteExtSys(ExternalSystemDefinition es)
{
if (!await Dialog.ConfirmAsync("Delete External System", $"Delete '{es.Name}'?", danger: true)) return;
try { await ExternalSystemRepository.DeleteExternalSystemAsync(es.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
// ==== Database Connections ====
private RenderFragment RenderDbConnections() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Database Connections</h5>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>Add Database Connection</button>
</div>
@if (_dbConnections.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No database connections configured.</p>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>
Add your first database connection
</button>
</div>
}
else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name…"
@bind="_dbConnSearch" @bind:event="oninput" />
</div>
@if (!FilteredDbConnections.Any())
{
<p class="text-muted small">No database connections match the filter.</p>
}
<div class="row g-3">
@foreach (var dc in FilteredDbConnections)
{
<div class="col-lg-6 col-12" @key="dc.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">@dc.Name</h5>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/db-connections/{dc.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {dc.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item text-danger" @onclick="() => DeleteDbConn(dc)">Delete</button></li>
</ul>
</div>
</div>
</div>
<p class="small text-muted text-truncate mb-1" title="@dc.ConnectionString">@dc.ConnectionString</p>
<div>
<span class="badge bg-light text-dark me-1">Max @dc.MaxRetries retries</span>
<span class="badge bg-light text-dark">Delay @dc.RetryDelay.TotalSeconds s</span>
</div>
</div>
</div>
</div>
}
</div>
}
};
private async Task DeleteDbConn(DatabaseConnectionDefinition dc)
{
if (!await Dialog.ConfirmAsync("Delete DB Connection", $"Delete '{dc.Name}'?", danger: true)) return;
try { await ExternalSystemRepository.DeleteDatabaseConnectionAsync(dc.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
// ==== Inbound API Methods ====
private RenderFragment RenderInboundApiMethods() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Inbound API Methods</h5>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>Add API Method</button>
</div>
@if (_apiMethods.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No API methods configured.</p>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>
Add your first API method
</button>
</div>
}
else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name…"
@bind="_apiMethodSearch" @bind:event="oninput" />
</div>
@if (!FilteredApiMethods.Any())
{
<p class="text-muted small">No API methods match the filter.</p>
}
<div class="row g-3">
@foreach (var m in FilteredApiMethods)
{
var preview = m.Script.Length > 80 ? m.Script[..80] + "…" : m.Script;
<div class="col-lg-6 col-12" @key="m.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-1">@m.Name</h5>
<code class="small">POST /api/@m.Name</code>
</div>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" @onclick='() => NavigationManager.NavigateTo($"/design/api-methods/{m.Id}/edit")'>Edit</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {m.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item text-danger" @onclick="() => DeleteApiMethod(m)">Delete</button></li>
</ul>
</div>
</div>
</div>
<pre class="small text-muted font-monospace mb-2"
style="white-space: pre-wrap; word-break: break-all;"
title="@m.Script">@preview</pre>
<span class="badge bg-light text-dark">Timeout @m.TimeoutSeconds s</span>
</div>
</div>
</div>
}
</div>
}
};
private async Task DeleteApiMethod(ApiMethod m)
{
if (!await Dialog.ConfirmAsync("Delete", $"Delete API method '{m.Name}'?", danger: true)) return;
try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
}
@@ -0,0 +1,369 @@
@page "/design/shared-scripts/create"
@page "/design/shared-scripts/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject SharedScriptService SharedScriptService
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">@(Id.HasValue ? $"Edit Shared Script: {_formName}" : "New Shared Script")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName"
disabled="@(Id.HasValue)" />
</div>
@* Tabs: Code, Parameters, Return. Panels stay mounted (toggled
via display:none) so the Monaco editor and the JSONJoy React
island don't tear down on tab switch. *@
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_formTab == "code" ? "active" : "")"
role="tab"
aria-selected="@(_formTab == "code" ? "true" : "false")"
@onclick='() => _formTab = "code"'>Code</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_formTab == "parameters" ? "active" : "")"
role="tab"
aria-selected="@(_formTab == "parameters" ? "true" : "false")"
@onclick='() => _formTab = "parameters"'>Parameters</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_formTab == "return" ? "active" : "")"
role="tab"
aria-selected="@(_formTab == "return" ? "true" : "false")"
@onclick='() => _formTab = "return"'>Return type</button>
</li>
</ul>
<div class="border border-top-0 rounded-bottom p-3">
<div style="display: @(_formTab == "code" ? "block" : "none")">
<MonacoEditor @ref="_editor" Value="@_formCode" ValueChanged="@(v => _formCode = v)"
Language="csharp" Height="320px"
DeclaredParameters="@ScriptParameterNames.Parse(_formParameters)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_formParameters)"
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
</div>
<div style="display: @(_formTab == "parameters" ? "block" : "none")">
<SchemaBuilder Mode="object"
Value="@_formParameters"
ValueChanged="@(v => _formParameters = v)" />
</div>
<div style="display: @(_formTab == "return" ? "block" : "none")">
<SchemaBuilder Mode="value"
Value="@_formReturn"
ValueChanged="@(v => _formReturn = v)" />
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
@if (_syntaxCheckResult != null)
{
<div class="@(_syntaxCheckPassed ? "text-success" : "text-danger") small mt-1">@_syntaxCheckResult</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
<button class="btn btn-outline-primary btn-sm me-1" @onclick="ToggleTestRunPanel">
@(_showTestRun ? "Hide Test Run" : "Test Run")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@if (_showTestRun)
{
<div class="card mb-3" id="test-run-panel">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
</div>
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
<strong>Heads up:</strong>
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
<code>Attributes</code> and <code>CallScript</code> still throw.
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label small">Parameter values</label>
<ParameterValueForm ParameterDefinitions="@_formParameters"
Values="_paramValues"
ValuesChanged="@(v => _paramValues = v)" />
</div>
<div class="d-flex gap-2 align-items-center mb-3">
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
@if (_running)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Running…</span>
}
else
{
<span>Run</span>
}
</button>
@if (_runResult != null)
{
<span class="text-muted small">@_runResult.DurationMs ms</span>
}
</div>
@if (_runResult != null)
{
@if (_runResult.Success)
{
<div class="mb-3">
<label class="form-label small text-success mb-1">
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
</label>
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
</div>
}
else
{
<div class="mb-3">
<label class="form-label small text-danger mb-1">
<span class="badge bg-danger me-1">@ErrorKindLabel(_runResult.ErrorKind)</span>
</label>
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
@if (_runResult.Markers is { Count: > 0 })
{
<ul class="small text-danger mt-2 mb-0">
@foreach (var m in _runResult.Markers)
{
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
}
</ul>
}
</div>
}
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
{
<div class="mb-0">
<label class="form-label small mb-1">Console output</label>
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
</div>
}
}
</div>
</div>
}
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading;
private string _formTab = "code"; // "code" | "parameters" | "return"
private string _formName = string.Empty;
private string _formCode = string.Empty;
private string? _formParameters;
private string? _formReturn;
private string? _formError;
private string? _syntaxCheckResult;
private bool _syntaxCheckPassed;
private MonacoEditor? _editor;
private IReadOnlyList<ScriptAnalysis.DiagnosticMarker> _markers = Array.Empty<ScriptAnalysis.DiagnosticMarker>();
private bool _showTestRun;
private bool _running;
private Dictionary<string, object?> _paramValues = new();
private ScriptAnalysis.SandboxRunResult? _runResult;
private CancellationTokenSource? _runCts;
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_loading = true;
try
{
var scripts = await SharedScriptService.GetAllSharedScriptsAsync();
var script = scripts.FirstOrDefault(s => s.Id == Id.Value);
if (script != null)
{
_formName = script.Name;
_formCode = script.Code;
_formParameters = script.ParameterDefinitions;
_formReturn = script.ReturnDefinition;
}
else
{
_formError = $"Shared script with ID {Id.Value} not found.";
}
}
catch (Exception ex)
{
_formError = $"Failed to load script: {ex.Message}";
}
_loading = false;
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/design/shared-scripts");
}
private void CheckCompilation()
{
var syntaxError = ValidateSyntaxLocally(_formCode);
if (syntaxError == null)
{
_syntaxCheckResult = "Syntax check passed.";
_syntaxCheckPassed = true;
}
else
{
_syntaxCheckResult = syntaxError;
_syntaxCheckPassed = false;
}
}
private async Task SaveScript()
{
_formError = null;
_syntaxCheckResult = null;
try
{
if (Id.HasValue)
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.UpdateSharedScriptAsync(
Id.Value, _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/design/shared-scripts");
}
else
{
_formError = result.Error;
}
}
else
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.CreateSharedScriptAsync(
_formName.Trim(), _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/design/shared-scripts");
}
else
{
_formError = result.Error;
}
}
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void ToggleTestRunPanel()
{
_showTestRun = !_showTestRun;
}
private async Task RunInSandboxAsync()
{
_runCts?.Cancel();
_runCts = new CancellationTokenSource();
_running = true;
_runResult = null;
StateHasChanged();
try
{
var jsonParams = _paramValues.ToDictionary(
kv => kv.Key,
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
var request = new ScriptAnalysis.SandboxRunRequest(_formCode, jsonParams, TimeoutSeconds: null);
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
}
catch (OperationCanceledException) { /* superseded by next Run click */ }
catch (Exception ex)
{
_runResult = new ScriptAnalysis.SandboxRunResult(
Success: false,
ReturnValueJson: null,
ReturnTypeName: null,
ConsoleOutput: "",
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError,
DurationMs: 0,
Markers: null);
}
finally
{
_running = false;
StateHasChanged();
}
}
private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch
{
ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
_ => "Error"
};
/// <summary>
/// Basic syntax check: balanced braces/brackets/parens.
/// Mirrors the internal SharedScriptService.ValidateSyntax logic.
/// </summary>
private static string? ValidateSyntaxLocally(string code)
{
if (string.IsNullOrWhiteSpace(code)) return "Script code cannot be empty.";
int brace = 0, bracket = 0, paren = 0;
foreach (var ch in code)
{
switch (ch) { case '{': brace++; break; case '}': brace--; break; case '[': bracket++; break; case ']': bracket--; break; case '(': paren++; break; case ')': paren--; break; }
if (brace < 0) return "Syntax error: unmatched closing brace '}'.";
if (bracket < 0) return "Syntax error: unmatched closing bracket ']'.";
if (paren < 0) return "Syntax error: unmatched closing parenthesis ')'.";
}
if (brace != 0) return "Syntax error: unmatched opening brace '{'.";
if (bracket != 0) return "Syntax error: unmatched opening bracket '['.";
if (paren != 0) return "Syntax error: unmatched opening parenthesis '('.";
return null;
}
}
@@ -0,0 +1,222 @@
@page "/design/shared-scripts"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject SharedScriptService SharedScriptService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Shared Scripts</h4>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/shared-scripts/create")'>New Script</button>
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else if (_scripts.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No shared scripts configured.</p>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/design/shared-scripts/create")'>
Create your first script
</button>
</div>
}
else
{
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name or code…"
@bind="_search" @bind:event="oninput" />
</div>
@if (!FilteredScripts.Any())
{
<p class="text-muted small">No shared scripts match the filter.</p>
}
<div class="row g-3">
@foreach (var s in FilteredScripts)
{
var preview = s.Code.Length > 80
? s.Code[..80] + "…"
: s.Code;
var paramCount = CountSchemaProperties(s.ParameterDefinitions);
var returnLabel = DescribeReturnType(s.ReturnDefinition);
<div class="col-lg-6 col-12" @key="s.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">@s.Name</h5>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{s.Id}/edit")'>
Edit
</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {s.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteScript(s)">Delete</button>
</li>
</ul>
</div>
</div>
</div>
<pre class="small text-muted font-monospace mb-2"
style="white-space: pre-wrap; word-break: break-all;"
title="@s.Code">@preview</pre>
<div>
<span class="badge bg-light text-dark me-1">@paramCount params</span>
<span class="badge bg-light text-dark">
@(returnLabel == "void" ? "void" : $"returns {returnLabel}")
</span>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
@code {
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private List<SharedScript> _scripts = new();
private bool _loading = true;
private string? _errorMessage;
private string _search = "";
private ToastNotification _toast = default!;
private IEnumerable<SharedScript> FilteredScripts =>
string.IsNullOrWhiteSpace(_search)
? _scripts
: _scripts.Where(s =>
(s.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) ||
(s.Code?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false));
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
_scripts = (await SharedScriptService.GetAllSharedScriptsAsync()).ToList();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load shared scripts: {ex.Message}";
}
_loading = false;
}
private async Task DeleteScript(SharedScript script)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Shared Script",
$"Delete shared script '{script.Name}'?",
danger: true);
if (!confirmed) return;
try
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.DeleteSharedScriptAsync(script.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Script '{script.Name}' deleted.");
await LoadDataAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
/// <summary>
/// Counts the parameters declared in either format: a JSON Schema object
/// (<c>{"type":"object","properties":{...}}</c>) or the legacy flat array.
/// Returns 0 for null/empty/malformed input.
/// </summary>
private static int CountSchemaProperties(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return 0;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind == System.Text.Json.JsonValueKind.Object
&& root.TryGetProperty("properties", out var props)
&& props.ValueKind == System.Text.Json.JsonValueKind.Object)
{
return props.EnumerateObject().Count();
}
if (root.ValueKind == System.Text.Json.JsonValueKind.Array)
return root.GetArrayLength();
}
catch { /* fall through */ }
return 0;
}
/// <summary>
/// Produces a short human label for a script's return type from its JSON
/// Schema definition: "string", "integer", "object", "string[]", etc.
/// Treats null/empty/malformed input as "void".
/// </summary>
private static string DescribeReturnType(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return "void";
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return "void";
if (!root.TryGetProperty("type", out var typeEl)) return "object";
var type = typeEl.GetString() ?? "object";
if (type == "array"
&& root.TryGetProperty("items", out var items)
&& items.ValueKind == System.Text.Json.JsonValueKind.Object
&& items.TryGetProperty("type", out var itemTypeEl))
{
return $"{itemTypeEl.GetString() ?? "object"}[]";
}
return type;
}
catch { return "void"; }
}
}
@@ -0,0 +1,126 @@
@page "/design/templates/create"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm"
aria-label="Back to Templates"
@onclick="GoBack">← Back</button>
</div>
<h4 class="mb-3">Create Template</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_createName" />
</div>
<div class="mb-3">
<label class="form-label">Parent Template</label>
<select class="form-select" @bind="_createParentId">
<option value="0">(None - root template)</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" @bind="_createDescription" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success me-1" @onclick="CreateTemplate">Create</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[SupplyParameterFromQuery] public int? FolderId { get; set; }
[SupplyParameterFromQuery] public int? ParentId { get; set; }
private List<Template> _templates = new();
private bool _loading = true;
private string _createName = string.Empty;
private int _createParentId;
private string? _createDescription;
private string? _formError;
protected override async Task OnInitializedAsync()
{
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
if (ParentId is int pid && _templates.Any(t => t.Id == pid))
{
_createParentId = pid;
}
}
catch (Exception ex)
{
_formError = $"Failed to load templates: {ex.Message}";
}
_loading = false;
}
private async Task CreateTemplate()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Name is required."; return; }
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync(
_createName.Trim(), _createDescription?.Trim(),
_createParentId == 0 ? null : _createParentId, user,
folderId: FolderId);
if (result.IsSuccess)
{
NavigationManager.NavigateTo($"/design/templates/{result.Value.Id}");
}
else
{
_formError = result.Error;
}
}
catch (Exception ex)
{
_formError = ex.Message;
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/design/templates");
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,525 @@
@page "/design/templates"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject TemplateFolderService TemplateFolderService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<RenameFolderDialog @bind-IsVisible="_showRenameFolderDialog"
FolderId="_renameFolderId"
InitialName="@_renameFolderInitialName"
ErrorMessage="@_renameFolderError"
OnSubmit="SubmitRenameFolder" />
<MoveTemplateDialog @bind-IsVisible="_showMoveTemplateDialog"
TemplateId="_moveTemplateId"
TemplateName="@_moveTemplateName"
FolderOptions="EnumerateFolderOptions()"
ErrorMessage="@_moveTemplateError"
OnSubmit="SubmitMoveTemplate" />
<MoveFolderDialog @bind-IsVisible="_showMoveFolderDialog"
FolderId="_moveFolderId"
FolderName="@_moveFolderName"
FolderOptions="EnumerateFolderOptionsExcluding(_moveFolderId)"
ErrorMessage="@_moveFolderError"
OnSubmit="SubmitMoveFolder" />
<ComposeIntoDialog @bind-IsVisible="_showComposeDialog"
SourceTemplateId="_composeSourceId"
SourceName="@_composeSourceName"
ParentOptions="EnumerateComposableParents(_composeSourceId)"
ErrorMessage="@_composeError"
OnSubmit="SubmitCompose" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Templates</h4>
<div class="d-flex gap-2">
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
data-bs-toggle="dropdown" aria-expanded="false">
Bulk actions
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="() => _tree.ExpandAll()">Expand all folders</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => _tree.CollapseAll()">Collapse all folders</button>
</li>
</ul>
</div>
<button class="btn btn-outline-secondary btn-sm"
title="New folder at root"
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
<button class="btn btn-primary btn-sm"
title="New template at root"
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
</div>
</div>
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
<TemplateFolderTree @ref="_tree"
Folders="_folders"
Templates="_templates"
SelectionMode="TreeViewSelectionMode.Single"
ExtraTemplateChildren="BuildCompositionLeavesFor"
StorageKey="templates-tree">
<NodeContent Context="node">
@RenderNodeLabel(node)
</NodeContent>
<ContextMenu Context="node">
@RenderNodeContextMenu(node)
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
</EmptyContent>
</TemplateFolderTree>
</div>
}
</div>
@code {
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private List<Template> _templates = new();
private List<TemplateFolder> _folders = new();
private bool _loading = true;
private string? _errorMessage;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
await LoadTemplatesAsync();
}
private async Task LoadTemplatesAsync()
{
_loading = true;
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
_templatesById = _templates.ToDictionary(t => t.Id);
_compositionsById = _templates
.SelectMany(t => t.Compositions)
.GroupBy(c => c.Id)
.ToDictionary(g => g.Key, g => g.First());
}
catch (Exception ex)
{
_errorMessage = $"Failed to load templates: {ex.Message}";
}
_loading = false;
}
// ID lookups so RenderNodeLabel / RenderNodeContextMenu can resolve the
// entity behind a TemplateTreeNode (whose payload is just Id+Kind+Name).
private Dictionary<int, Template> _templatesById = new();
private Dictionary<int, TemplateComposition> _compositionsById = new();
// Composition-leaf builder for TemplateFolderTree's ExtraTemplateChildren
// hook: walks each template's compositions recursively so cascaded slots
// appear as nested children. The Transport Export wizard intentionally
// does NOT supply this hook — compositions aren't independently exportable.
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(Template owner)
{
var result = new List<TemplateTreeNode>();
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
{
var node = new TemplateTreeNode
{
Kind = TemplateTreeNodeKind.Composition,
Id = c.Id,
Name = c.InstanceName,
};
if (_templatesById.TryGetValue(c.ComposedTemplateId, out var composed))
{
foreach (var nested in BuildCompositionLeavesFor(composed))
{
node.Children.Add(nested);
}
}
result.Add(node);
}
return result;
}
private TemplateFolderTree _tree = default!;
private void OpenTemplate(int templateId) =>
NavigationManager.NavigateTo($"/design/templates/{templateId}");
private RenderFragment RenderNodeLabel(TemplateTreeNode node) => __builder =>
{
switch (node.Kind)
{
case TemplateTreeNodeKind.Folder:
<span class="tv-glyph"><i class="bi bi-folder"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Name">@node.Name</span>
@if (node.Children.Count > 0)
{
<span class="tv-meta">
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
</span>
}
break;
case TemplateTreeNodeKind.Template:
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Name"
@ondblclick="() => OpenTemplate(node.Id)">@node.Name</span>
break;
case TemplateTreeNodeKind.Composition:
var composedId = _compositionsById.TryGetValue(node.Id, out var comp) ? comp.ComposedTemplateId : 0;
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
<span class="tv-label" title="@node.Name"
@ondblclick="() => OpenTemplate(composedId)">@node.Name</span>
break;
}
};
private RenderFragment RenderNodeContextMenu(TemplateTreeNode node) => __builder =>
{
switch (node.Kind)
{
case TemplateTreeNodeKind.Folder:
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.Id)">New Folder</button>
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.Id}")'>New Template</button>
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.Id, node.Name)">Rename</button>
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.Id, node.Name)">Move to Folder…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.Id, node.Name)">Delete</button>
break;
case TemplateTreeNodeKind.Template:
var tmpl = _templatesById.TryGetValue(node.Id, out var t) ? t : null;
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.Id}")'>New Inheriting Template</button>
@if (tmpl != null)
{
<button class="dropdown-item" @onclick="() => OpenComposeDialog(tmpl)">Compose into…</button>
}
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.Id, node.Name)">Move to Folder…</button>
<div class="dropdown-divider"></div>
@if (tmpl != null)
{
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(tmpl)">Delete</button>
}
break;
case TemplateTreeNodeKind.Composition:
if (_compositionsById.TryGetValue(node.Id, out var ctx))
{
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{ctx.ComposedTemplateId}")'>Open composed template</button>
<button class="dropdown-item" @onclick="() => RenameComposition(ctx)">Rename…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(ctx)">Delete</button>
}
break;
}
};
// New-folder dialog: replaced the dedicated <NewFolderDialog> component with
// IDialogService.PromptAsync. Validation failures surface via toast instead of
// inline error text — the prompt UI doesn't have a slot for an error message.
private async Task OpenNewFolderDialog(int? parentFolderId)
{
var name = await Dialog.PromptAsync("New folder", "Folder name", placeholder: "Folder name");
if (string.IsNullOrWhiteSpace(name)) return;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.CreateFolderAsync(name.Trim(), parentFolderId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Folder '{result.Value.Name}' created.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
// Move-template dialog state
private bool _showMoveTemplateDialog;
private int _moveTemplateId;
private string _moveTemplateName = string.Empty;
private string? _moveTemplateError;
private void OpenMoveTemplateDialog(int templateId, string label)
{
_moveTemplateId = templateId;
_moveTemplateName = label;
_moveTemplateError = null;
_showMoveTemplateDialog = true;
}
private async Task SubmitMoveTemplate((int TemplateId, int? NewFolderId) req)
{
_moveTemplateError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateService.MoveTemplateAsync(req.TemplateId, req.NewFolderId, user);
if (result.IsSuccess)
{
_showMoveTemplateDialog = false;
_toast.ShowSuccess($"Template '{_moveTemplateName}' moved.");
await LoadTemplatesAsync();
}
else
{
_moveTemplateError = result.Error;
}
}
// Flat list of folders with indentation labels, for the picker.
private IEnumerable<(int? Id, string Label)> EnumerateFolderOptions()
{
yield return (null, "(Root)");
foreach (var f in WalkFolderHierarchy(_folders.Where(f => f.ParentFolderId == null), 0, excludeFolderId: null))
yield return f;
}
// Same as EnumerateFolderOptions, but prunes the given folder and all its descendants
// so the move dialog can't surface an obvious cycle target (server still validates).
private IEnumerable<(int? Id, string Label)> EnumerateFolderOptionsExcluding(int excludeFolderId)
{
yield return (null, "(Root)");
foreach (var f in WalkFolderHierarchy(_folders.Where(f => f.ParentFolderId == null), 0, excludeFolderId))
yield return f;
}
private IEnumerable<(int? Id, string Label)> WalkFolderHierarchy(IEnumerable<TemplateFolder> level, int depth, int? excludeFolderId)
{
foreach (var f in level.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
{
if (excludeFolderId.HasValue && f.Id == excludeFolderId.Value) continue;
yield return ((int?)f.Id, new string(' ', depth * 2) + f.Name);
foreach (var sub in WalkFolderHierarchy(_folders.Where(c => c.ParentFolderId == f.Id), depth + 1, excludeFolderId))
yield return sub;
}
}
// Move-folder dialog state
private bool _showMoveFolderDialog;
private int _moveFolderId;
private string _moveFolderName = string.Empty;
private string? _moveFolderError;
private void OpenMoveFolderDialog(int folderId, string label)
{
_moveFolderId = folderId;
_moveFolderName = label;
_moveFolderError = null;
_showMoveFolderDialog = true;
}
private async Task SubmitMoveFolder((int FolderId, int? NewParentId) req)
{
_moveFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.MoveFolderAsync(req.FolderId, req.NewParentId, user);
if (result.IsSuccess)
{
_showMoveFolderDialog = false;
_toast.ShowSuccess($"Folder '{_moveFolderName}' moved.");
await LoadTemplatesAsync();
}
else
{
_moveFolderError = result.Error;
}
}
// Rename folder dialog state
private bool _showRenameFolderDialog;
private int _renameFolderId;
private string _renameFolderInitialName = string.Empty;
private string? _renameFolderError;
private void OpenRenameFolderDialog(int folderId, string currentName)
{
_renameFolderId = folderId;
_renameFolderInitialName = currentName;
_renameFolderError = null;
_showRenameFolderDialog = true;
}
private async Task SubmitRenameFolder((int FolderId, string NewName) req)
{
_renameFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.RenameFolderAsync(req.FolderId, req.NewName, user);
if (result.IsSuccess)
{
_showRenameFolderDialog = false;
_toast.ShowSuccess("Folder renamed.");
await LoadTemplatesAsync();
}
else
{
_renameFolderError = result.Error;
}
}
private async Task DeleteFolder(int folderId, string label)
{
var confirmed = await Dialog.ConfirmAsync("Delete Folder", $"Delete folder '{label}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.DeleteFolderAsync(folderId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Folder '{label}' deleted.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
private async Task DeleteTemplate(Template template)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Template",
$"Delete template '{template.Name}'? This will fail if instances or child templates reference it.",
danger: true);
if (!confirmed) return;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteTemplateAsync(template.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Template '{template.Name}' deleted.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
// ---- Compose-into dialog ----
private bool _showComposeDialog;
private int _composeSourceId;
private string _composeSourceName = string.Empty;
private string? _composeError;
private void OpenComposeDialog(Template source)
{
_composeSourceId = source.Id;
_composeSourceName = source.Name;
_composeError = null;
_showComposeDialog = true;
}
// Possible parents for a compose: every non-derived template except the source itself.
// Server still validates cycles + collisions; the picker just trims obvious bad choices.
private IEnumerable<(int Id, string Label)> EnumerateComposableParents(int sourceId)
{
return _templates
.Where(t => !t.IsDerived && t.Id != sourceId)
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.Select(t => (t.Id, t.Name));
}
private async Task SubmitCompose((int SourceTemplateId, int ParentTemplateId, string SlotName) req)
{
_composeError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddCompositionAsync(req.ParentTemplateId, req.SourceTemplateId, req.SlotName, user);
if (result.IsSuccess)
{
_showComposeDialog = false;
_toast.ShowSuccess($"Composed '{_composeSourceName}' as '{req.SlotName}'.");
await LoadTemplatesAsync();
}
else
{
_composeError = result.Error;
}
}
// ---- Composition leaf: rename + delete ----
private async Task RenameComposition(TemplateComposition composition)
{
var newName = await Dialog.PromptAsync(
"Rename slot",
$"New name for slot '{composition.InstanceName}':",
initialValue: composition.InstanceName,
placeholder: "Slot name");
if (string.IsNullOrWhiteSpace(newName) || newName.Trim() == composition.InstanceName) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.RenameCompositionAsync(composition.Id, newName.Trim(), user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Slot renamed to '{newName.Trim()}'.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
private async Task DeleteComposition(TemplateComposition composition)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete composition",
$"Delete slot '{composition.InstanceName}'? This removes the derived template and any overrides on it.",
danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteCompositionAsync(composition.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Composition '{composition.InstanceName}' removed.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
}
@@ -0,0 +1,468 @@
@page "/design/transport/export"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@*
TransportExport wizard (Component #24, Task T21).
A 4-step linear wizard:
Step 1 — Select : templates (tree, checkbox-mode) + flat artifact lists.
Step 2 — Review : resolved closure split into seed vs auto-included.
Step 3 — Encrypt : passphrase + confirm, or explicit unencrypted opt-out.
Step 4 — Download : streams the bundle bytes to the browser via JS interop.
The page is Design-role gated; deeper interactions (audit row, secrets
warning) come from BundleExporter itself.
*@
<div class="container-fluid mt-3">
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<h4 class="mb-3">Export Bundle</h4>
@* Step indicator — Bootstrap progress with discrete numbered pills. *@
<nav aria-label="Export wizard steps" class="mb-4">
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
<li class="@StepClass(ExportWizardStep.Select)">
<span class="badge rounded-pill me-1">1</span> Select
</li>
<li class="@StepClass(ExportWizardStep.Review)">
<span class="badge rounded-pill me-1">2</span> Review
</li>
<li class="@StepClass(ExportWizardStep.Encrypt)">
<span class="badge rounded-pill me-1">3</span> Encrypt
</li>
<li class="@StepClass(ExportWizardStep.Download)">
<span class="badge rounded-pill me-1">4</span> Download
</li>
</ol>
</nav>
@switch (_step)
{
case ExportWizardStep.Select:
@RenderStepSelect();
break;
case ExportWizardStep.Review:
@RenderStepReview();
break;
case ExportWizardStep.Encrypt:
@RenderStepEncrypt();
break;
case ExportWizardStep.Download:
@RenderStepDownload();
break;
}
}
</div>
@code {
private string StepClass(ExportWizardStep s) =>
s == _step ? "fw-semibold text-primary"
: (int)s < (int)_step ? "text-success"
: "text-muted";
// ============================================================
// Step 1 — Select
// ============================================================
private RenderFragment RenderStepSelect() => __builder =>
{
<div>
<div class="mb-3">
<label for="export-filter" class="form-label">Search</label>
<input id="export-filter" type="search" class="form-control"
placeholder="Filter all artifacts…"
@bind="_filter" @bind:event="oninput" />
</div>
<fieldset class="mb-4" data-testid="group-templates">
<legend class="h6">Templates</legend>
@if (_templates.Count == 0)
{
<div class="text-muted small fst-italic">No templates.</div>
}
else
{
<div style="max-height: 320px; overflow-y: auto; padding: 4px; border: 1px solid var(--bs-border-color); border-radius: 4px;">
<TemplateFolderTree Folders="_folders"
Templates="_templates"
SelectionMode="TreeViewSelectionMode.Checkbox"
SelectedKeys="_selectedTemplateKeys"
SelectedKeysChanged="OnTemplateSelectionChanged"
Filter="@_filter" />
</div>
}
</fieldset>
<fieldset class="mb-4" data-testid="group-shared-scripts">
<legend class="h6">Shared Scripts</legend>
@RenderCheckboxList(_sharedScripts, s => s.Id, s => s.Name, _selectedSharedScripts)
</fieldset>
<fieldset class="mb-4" data-testid="group-external-systems">
<legend class="h6">External Systems</legend>
@RenderCheckboxList(_externalSystems, e => e.Id, e => e.Name, _selectedExternalSystems)
</fieldset>
<fieldset class="mb-4" data-testid="group-db-connections">
<legend class="h6">Database Connections</legend>
@RenderCheckboxList(_dbConnections, d => d.Id, d => d.Name, _selectedDbConnections)
</fieldset>
<fieldset class="mb-4" data-testid="group-notification-lists">
<legend class="h6">Notification Lists</legend>
@RenderCheckboxList(_notificationLists, n => n.Id, n => n.Name, _selectedNotificationLists)
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="smtp-hint">
Selecting a notification list does <strong>not</strong> automatically include its
SMTP configuration. SMTP configurations are environment-specific and must be
selected separately if you want them in the bundle.
</div>
</fieldset>
<fieldset class="mb-4" data-testid="group-smtp-configs">
<legend class="h6">SMTP Configurations</legend>
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
</fieldset>
<fieldset class="mb-4" data-testid="group-api-keys">
<legend class="h6">API Keys</legend>
@RenderCheckboxList(_apiKeys, k => k.Id, k => k.Name, _selectedApiKeys)
</fieldset>
<fieldset class="mb-4" data-testid="group-api-methods">
<legend class="h6">API Methods</legend>
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
</fieldset>
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-primary"
disabled="@(!HasAnySelection || _resolving)"
@onclick="GoToReviewAsync">
@(_resolving ? "Resolving…" : "Next")
</button>
</div>
</div>
};
private void OnTemplateSelectionChanged(HashSet<object> keys)
{
// TemplateFolderTree hands back a fresh HashSet each time; mirror it
// into our owned set so subsequent renders see the same instance the
// tree is binding against.
_selectedTemplateKeys.Clear();
foreach (var k in keys)
{
_selectedTemplateKeys.Add(k);
}
}
private RenderFragment RenderCheckboxList<T>(
IReadOnlyList<T> items,
Func<T, int> idOf,
Func<T, string> nameOf,
HashSet<int> selected) => __builder =>
{
var visible = items.Where(x => MatchesFilter(nameOf(x))).ToList();
if (visible.Count == 0)
{
<div class="text-muted small fst-italic">No matches.</div>
return;
}
<div class="d-flex flex-column gap-1">
@foreach (var item in visible)
{
var id = idOf(item);
var inputId = $"chk-{typeof(T).Name}-{id}";
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@inputId"
checked="@selected.Contains(id)"
@onchange="e => Toggle(selected, id, ((bool?)e.Value) == true)" />
<label class="form-check-label" for="@inputId">@nameOf(item)</label>
</div>
}
</div>
};
// ============================================================
// Step 2 — Review
// ============================================================
private RenderFragment RenderStepReview() => __builder =>
{
if (_resolved is null)
{
<div class="alert alert-warning">Nothing resolved yet — please go back to step 1.</div>
return;
}
var seedTemplateIds = new HashSet<int>(SelectedTemplateIds());
var seedSharedScripts = new HashSet<int>(_selectedSharedScripts);
var seedExternalSystems = new HashSet<int>(_selectedExternalSystems);
var autoTemplates = AutoIncluded(_resolved.Templates, seedTemplateIds, t => t.Id);
var autoShared = AutoIncluded(_resolved.SharedScripts, seedSharedScripts, s => s.Id);
var autoExternals = AutoIncluded(_resolved.ExternalSystems, seedExternalSystems, e => e.Id);
<div>
<p class="text-body-secondary">
The resolver walked your selection's dependency graph and produced the closure
below. Items under <em>Auto-included</em> were pulled in because the items you
ticked reference them; unticking
<em>Include all dependencies</em> exports the seed alone.
</p>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="include-deps"
checked="@_includeDependencies"
@onchange="async e => { _includeDependencies = ((bool?)e.Value) == true; await ReresolveAsync(); }" />
<label class="form-check-label" for="include-deps">Include all dependencies</label>
</div>
<div class="row g-3">
<div class="col-md-6" data-testid="seed-group">
<h6>Selected by you</h6>
<ul class="small list-unstyled mb-0">
@foreach (var t in _resolved.Templates.Where(t => seedTemplateIds.Contains(t.Id)).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
{
<li>Template: @t.Name</li>
}
@foreach (var s in _resolved.SharedScripts.Where(s => seedSharedScripts.Contains(s.Id)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
{
<li>SharedScript: @s.Name</li>
}
@foreach (var e in _resolved.ExternalSystems.Where(e => seedExternalSystems.Contains(e.Id)).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
{
<li>ExternalSystem: @e.Name</li>
}
@foreach (var d in _resolved.DatabaseConnections.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase))
{
<li>DatabaseConnection: @d.Name</li>
}
@foreach (var n in _resolved.NotificationLists.OrderBy(n => n.Name, StringComparer.OrdinalIgnoreCase))
{
<li>NotificationList: @n.Name</li>
}
@foreach (var s in _resolved.SmtpConfigs.OrderBy(s => s.Host, StringComparer.OrdinalIgnoreCase))
{
<li>SmtpConfig: @s.Host</li>
}
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase))
{
<li>ApiKey: @k.Name</li>
}
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
{
<li>ApiMethod: @m.Name</li>
}
</ul>
</div>
<div class="col-md-6" data-testid="auto-group">
<h6>Auto-included (dependencies)</h6>
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count + _resolved.TemplateFolders.Count == 0)
{
<div class="small text-muted fst-italic">No additional dependencies.</div>
}
else
{
<ul class="small list-unstyled mb-0">
@foreach (var f in _resolved.TemplateFolders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
{
<li>TemplateFolder: @f.Name</li>
}
@foreach (var t in autoTemplates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
{
<li>Template: @t.Name</li>
}
@foreach (var s in autoShared.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
{
<li>SharedScript: @s.Name</li>
}
@foreach (var e in autoExternals.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
{
<li>ExternalSystem: @e.Name</li>
}
</ul>
}
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<button class="btn btn-outline-secondary" @onclick="BackToSelect">Back</button>
<button class="btn btn-primary" @onclick="GoToEncrypt">Next</button>
</div>
</div>
};
// ============================================================
// Step 3 — Encrypt
// ============================================================
private RenderFragment RenderStepEncrypt() => __builder =>
{
var strength = PassphraseStrength(_passphrase);
var strengthLabel = strength switch
{
0 => "Too short",
1 => "Weak",
2 => "Fair",
3 => "Good",
_ => "Strong",
};
var strengthColor = strength switch
{
<= 1 => "bg-danger",
2 => "bg-warning",
3 => "bg-info",
_ => "bg-success",
};
<div>
@if (_secretCount > 0)
{
<div class="alert alert-warning" role="alert" data-testid="secrets-warning">
<strong>@_secretCount</strong> secret @(_secretCount == 1 ? "field" : "fields")
will be encrypted (external-system credentials, SMTP credentials, and database
connection strings).
</div>
}
else
{
<div class="alert alert-info small" role="alert" data-testid="secrets-warning">
The selected closure contains no secret fields, but the bundle's content
payload will still be encrypted in full when a passphrase is supplied.
</div>
}
<div class="mb-3">
<label for="passphrase" class="form-label">Passphrase</label>
<input id="passphrase" type="password" class="form-control"
autocomplete="new-password"
@bind="_passphrase" @bind:event="oninput" />
<div class="progress mt-1" style="height: 4px;">
<div class="progress-bar @strengthColor"
role="progressbar"
style="width: @(strength * 25)%;"
aria-valuenow="@(strength * 25)" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<div class="form-text">Strength: @strengthLabel · minimum 8 characters.</div>
</div>
<div class="mb-3">
<label for="passphrase-confirm" class="form-label">Confirm passphrase</label>
<input id="passphrase-confirm" type="password" class="form-control"
autocomplete="new-password"
@bind="_passphraseConfirm" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(_passphraseConfirm) && _passphrase != _passphraseConfirm)
{
<div class="form-text text-danger">Passphrases do not match.</div>
}
</div>
<p class="small">
<a href="javascript:void(0)" class="link-danger" @onclick="OpenUnencryptedConfirm">
Export without encryption…
</a>
</p>
@if (_showUnencryptedConfirm)
{
<div class="alert alert-danger" data-testid="unencrypted-confirm">
<strong>Unencrypted export</strong> — the bundle will contain all secret fields
in plaintext. Anyone with the file can read external-system credentials, SMTP
passwords, and database connection strings. The audit log will record this as
<code>UnencryptedBundleExport</code>.
<div class="mt-2 d-flex gap-2">
<button class="btn btn-sm btn-danger" @onclick="ConfirmUnencryptedExport">
Yes, export without encryption
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelUnencryptedConfirm">
Cancel
</button>
</div>
</div>
}
<div class="d-flex justify-content-between mt-4">
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
<button class="btn btn-primary"
disabled="@(!PassphraseValid)"
@onclick="StartEncryptedExportAsync">
Export
</button>
</div>
</div>
};
// ============================================================
// Step 4 — Download
// ============================================================
private RenderFragment RenderStepDownload() => __builder =>
{
<div>
@if (_downloadInProgress)
{
<LoadingSpinner IsLoading="true" />
<p class="text-body-secondary">Building bundle…</p>
}
else if (_downloadError != null)
{
<div class="alert alert-danger">
<strong>Export failed:</strong> @_downloadError
</div>
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
}
else
{
<div class="alert alert-success" data-testid="download-summary">
<strong>Bundle ready.</strong> Your browser is downloading the file.
</div>
<dl class="row small">
<dt class="col-sm-3">Filename</dt>
<dd class="col-sm-9"><code>@_downloadFilename</code></dd>
<dt class="col-sm-3">Size</dt>
<dd class="col-sm-9">@FormatBytes(_downloadSize)</dd>
<dt class="col-sm-3">SHA-256</dt>
<dd class="col-sm-9"><code>@_downloadSha256</code></dd>
<dt class="col-sm-3">Encryption</dt>
<dd class="col-sm-9">
@if (_exportUnencrypted)
{
<span class="text-danger">Unencrypted (audited as <code>UnencryptedBundleExport</code>)</span>
}
else
{
<span class="text-success">AES-256-GCM with PBKDF2-SHA256</span>
}
</dd>
</dl>
<button class="btn btn-primary" @onclick="Done">Done</button>
}
</div>
};
private static string FormatBytes(long bytes)
{
if (bytes < 1024) return $"{bytes} B";
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:0.0} KB";
return $"{bytes / (1024.0 * 1024.0):0.00} MB";
}
}
@@ -0,0 +1,436 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Export;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design;
/// <summary>
/// Code-behind for the TransportExport wizard (Transport feature, Task T21).
///
/// Four-step state machine:
/// <list type="number">
/// <item><see cref="ExportWizardStep.Select"/> — pick templates + flat artifact lists.</item>
/// <item><see cref="ExportWizardStep.Review"/> — show resolved closure (seed + auto-included).</item>
/// <item><see cref="ExportWizardStep.Encrypt"/> — passphrase + secret-count warning, or explicit unencrypted opt-out.</item>
/// <item><see cref="ExportWizardStep.Download"/> — call <see cref="IBundleExporter"/>, render the file via JS interop.</item>
/// </list>
///
/// The wizard transitions are linear; "Back" returns to the previous step
/// without clearing selection state. "Done" on Step 4 resets to Step 1 fresh.
///
/// <c>SourceEnvironment</c> is sourced from <see cref="TransportOptions.SourceEnvironment"/>
/// (bound from <c>ScadaBridge:Transport:SourceEnvironment</c>) so a multi-cluster
/// deployment can label its bundles distinctly. Defaults to <c>"scadabridge"</c>.
/// </summary>
public partial class TransportExport : ComponentBase
{
public enum ExportWizardStep
{
Select = 1,
Review = 2,
Encrypt = 3,
Download = 4,
}
// ---- Injected services ----
[Inject] private IBundleExporter BundleExporter { get; set; } = default!;
[Inject] private ITemplateEngineRepository TemplateRepo { get; set; } = default!;
[Inject] private IExternalSystemRepository ExternalRepo { get; set; } = default!;
[Inject] private INotificationRepository NotificationRepo { get; set; } = default!;
[Inject] private IInboundApiRepository InboundApiRepo { get; set; } = default!;
[Inject] private DependencyResolver DepResolver { get; set; } = default!;
[Inject] private IJSRuntime JS { get; set; } = default!;
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
[Inject] private IOptions<TransportOptions> TransportOptions { get; set; } = default!;
// ---- Wizard state ----
private ExportWizardStep _step = ExportWizardStep.Select;
private bool _loading = true;
private string? _errorMessage;
// ---- Step 1: source data ----
private List<Template> _templates = new();
private List<TemplateFolder> _folders = new();
private List<SharedScript> _sharedScripts = new();
private List<ExternalSystemDefinition> _externalSystems = new();
private List<DatabaseConnectionDefinition> _dbConnections = new();
private List<NotificationList> _notificationLists = new();
private List<SmtpConfiguration> _smtpConfigs = new();
private List<ApiKey> _apiKeys = new();
private List<ApiMethod> _apiMethods = new();
// ---- Step 1: selection state ----
// TemplateFolderTree uses string keys ("t:{id}", "f:{id}") via TemplateTreeNode.Key.
// Templates are selected via the tree; the other artifacts use flat checkbox lists
// backed by integer-id HashSets so wiring is uniform across categories.
private readonly HashSet<object> _selectedTemplateKeys = new();
private readonly HashSet<int> _selectedSharedScripts = new();
private readonly HashSet<int> _selectedExternalSystems = new();
private readonly HashSet<int> _selectedDbConnections = new();
private readonly HashSet<int> _selectedNotificationLists = new();
private readonly HashSet<int> _selectedSmtpConfigs = new();
private readonly HashSet<int> _selectedApiKeys = new();
private readonly HashSet<int> _selectedApiMethods = new();
private string _filter = string.Empty;
private bool _includeDependencies = true;
// ---- Step 2: dependency-resolved closure ----
private ResolvedExport? _resolved;
private bool _resolving;
// ---- Step 3: encryption ----
private string _passphrase = string.Empty;
private string _passphraseConfirm = string.Empty;
private bool _showUnencryptedConfirm;
private bool _exportUnencrypted;
private int _secretCount;
// ---- Step 4: download result ----
private string? _downloadFilename;
private long _downloadSize;
private string? _downloadSha256;
private bool _downloadInProgress;
private string? _downloadError;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await LoadAllAsync();
}
private async Task LoadAllAsync()
{
_loading = true;
_errorMessage = null;
try
{
_templates = (await TemplateRepo.GetAllTemplatesAsync()).ToList();
_folders = (await TemplateRepo.GetAllFoldersAsync()).ToList();
_sharedScripts = (await TemplateRepo.GetAllSharedScriptsAsync()).ToList();
_externalSystems = (await ExternalRepo.GetAllExternalSystemsAsync()).ToList();
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
_apiKeys = (await InboundApiRepo.GetAllApiKeysAsync()).ToList();
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load export source data: {ex.Message}";
}
finally
{
_loading = false;
}
}
// ---- Selection helpers ----
/// <summary>
/// Project the tree's checkbox-keys back to template ids. Template keys are
/// the strings produced by <c>TemplateTreeNode.Key</c> — folder ids are
/// excluded (folders aren't directly exportable; their templates are).
/// </summary>
private IReadOnlyList<int> SelectedTemplateIds()
{
var ids = new List<int>();
foreach (var key in _selectedTemplateKeys)
{
if (key is string s && s.StartsWith("t:", StringComparison.Ordinal)
&& int.TryParse(s.AsSpan(2), out var id))
{
ids.Add(id);
}
}
return ids;
}
/// <summary>
/// True when the user has ticked at least one item in any category. Drives
/// the "Next" button on Step 1.
/// </summary>
internal bool HasAnySelection =>
SelectedTemplateIds().Count > 0
|| _selectedSharedScripts.Count > 0
|| _selectedExternalSystems.Count > 0
|| _selectedDbConnections.Count > 0
|| _selectedNotificationLists.Count > 0
|| _selectedSmtpConfigs.Count > 0
|| _selectedApiKeys.Count > 0
|| _selectedApiMethods.Count > 0;
private bool PassphraseValid =>
!string.IsNullOrEmpty(_passphrase)
&& _passphrase.Length >= 8
&& _passphrase == _passphraseConfirm;
/// <summary>
/// Coarse strength score 0-4 based on length + character-class diversity. Used
/// to colour an inline strength meter; never used to gate the export — the
/// importer enforces its own strength + lockout policies.
/// </summary>
/// <param name="s">The passphrase string to score.</param>
internal static int PassphraseStrength(string s)
{
if (string.IsNullOrEmpty(s)) return 0;
var score = 0;
if (s.Length >= 8) score++;
if (s.Length >= 16) score++;
if (s.Any(char.IsUpper) && s.Any(char.IsLower)) score++;
if (s.Any(char.IsDigit) && s.Any(ch => !char.IsLetterOrDigit(ch))) score++;
return Math.Min(4, score);
}
// ---- Wizard transitions ----
private ExportSelection BuildSelection()
{
return new ExportSelection(
TemplateIds: SelectedTemplateIds(),
SharedScriptIds: _selectedSharedScripts.ToList(),
ExternalSystemIds: _selectedExternalSystems.ToList(),
DatabaseConnectionIds: _selectedDbConnections.ToList(),
NotificationListIds: _selectedNotificationLists.ToList(),
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
ApiKeyIds: _selectedApiKeys.ToList(),
ApiMethodIds: _selectedApiMethods.ToList(),
IncludeDependencies: _includeDependencies);
}
private async Task GoToReviewAsync()
{
if (!HasAnySelection) return;
_resolving = true;
_errorMessage = null;
try
{
var selection = BuildSelection();
_resolved = await DepResolver.ResolveAsync(selection, CancellationToken.None);
_step = ExportWizardStep.Review;
}
catch (Exception ex)
{
_errorMessage = $"Failed to resolve dependencies: {ex.Message}";
}
finally
{
_resolving = false;
}
}
private async Task ReresolveAsync()
{
// Re-run resolution from Step 2 when the user toggles IncludeDependencies.
_resolving = true;
try
{
_resolved = await DepResolver.ResolveAsync(BuildSelection(), CancellationToken.None);
}
finally
{
_resolving = false;
}
}
private void GoToEncrypt()
{
// Recompute the secret-field count from the resolved closure so the
// warning banner stays honest if the user backed up and re-resolved.
if (_resolved is not null)
{
_secretCount = CountSecrets(_resolved);
}
_step = ExportWizardStep.Encrypt;
}
/// <summary>
/// Count the secret fields that <see cref="BundleSecretEncryptor"/> will
/// envelope-encrypt. Surfaces in the Step 3 warning banner so the user
/// knows exactly what an unencrypted export would leak.
/// </summary>
/// <param name="resolved">The resolved export closure whose secret fields are counted.</param>
internal static int CountSecrets(ResolvedExport resolved)
{
var count = 0;
foreach (var es in resolved.ExternalSystems)
{
if (!string.IsNullOrEmpty(es.AuthConfiguration)) count++;
}
foreach (var smtp in resolved.SmtpConfigs)
{
if (!string.IsNullOrEmpty(smtp.Credentials)) count++;
}
foreach (var db in resolved.DatabaseConnections)
{
if (!string.IsNullOrEmpty(db.ConnectionString)) count++;
}
return count;
}
private void BackToSelect() => _step = ExportWizardStep.Select;
private void BackToReview() => _step = ExportWizardStep.Review;
private void OpenUnencryptedConfirm()
{
_showUnencryptedConfirm = true;
}
private async Task ConfirmUnencryptedExport()
{
_showUnencryptedConfirm = false;
_exportUnencrypted = true;
await StartExportAsync(passphrase: null);
}
private void CancelUnencryptedConfirm()
{
_showUnencryptedConfirm = false;
}
private async Task StartEncryptedExportAsync()
{
if (!PassphraseValid) return;
_exportUnencrypted = false;
await StartExportAsync(_passphrase);
}
/// <summary>
/// Final export step: invokes <see cref="IBundleExporter.ExportAsync"/>,
/// captures the bundle bytes, computes a display-side SHA-256 (matching
/// the manifest's content hash naming), and pushes the file to the browser
/// via JS interop. Errors surface inline; the page never throws to the user.
/// </summary>
private async Task StartExportAsync(string? passphrase)
{
_step = ExportWizardStep.Download;
_downloadInProgress = true;
_downloadError = null;
try
{
var user = await Auth.GetCurrentUsernameAsync();
var sourceEnv = TransportOptions.Value.SourceEnvironment;
if (string.IsNullOrWhiteSpace(sourceEnv))
{
sourceEnv = "scadabridge";
}
var selection = BuildSelection();
await using var stream = await BundleExporter.ExportAsync(
selection, user, sourceEnv, passphrase, CancellationToken.None);
byte[] bytes;
if (stream is MemoryStream ms)
{
bytes = ms.ToArray();
}
else
{
using var copy = new MemoryStream();
await stream.CopyToAsync(copy);
bytes = copy.ToArray();
}
_downloadSize = bytes.LongLength;
_downloadSha256 = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
_downloadFilename = BuildFilename(sourceEnv);
var base64 = Convert.ToBase64String(bytes);
await JS.InvokeVoidAsync("scadabridgeTransport.downloadBundle", _downloadFilename, base64);
}
catch (Exception ex)
{
_downloadError = ex.Message;
}
finally
{
_downloadInProgress = false;
}
}
/// <summary>
/// Filename pattern <c>scadabundle-{sourceEnv}-{yyyy-MM-dd-HHmmss}.scadabundle</c>.
/// Source environment characters are sanitised to a filename-safe alphabet so
/// odd chars in <c>TransportOptions.SourceEnvironment</c> don't produce
/// browser-rejected filenames.
/// </summary>
/// <param name="sourceEnvironment">The environment label to embed in the filename (sanitised to filename-safe characters).</param>
/// <param name="nowUtc">Timestamp to use for the datetime segment; defaults to <see cref="DateTimeOffset.UtcNow"/> when null.</param>
internal static string BuildFilename(string sourceEnvironment, DateTimeOffset? nowUtc = null)
{
var safe = SanitizeForFilename(sourceEnvironment);
var ts = (nowUtc ?? DateTimeOffset.UtcNow).ToString("yyyy-MM-dd-HHmmss");
return $"scadabundle-{safe}-{ts}.scadabundle";
}
private static string SanitizeForFilename(string input)
{
if (string.IsNullOrWhiteSpace(input)) return "scadabridge";
var chars = input.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
return new string(chars);
}
private void Done()
{
// Reset every wizard piece so the operator can immediately start a fresh
// export without page-refresh-induced data reload.
_step = ExportWizardStep.Select;
_selectedTemplateKeys.Clear();
_selectedSharedScripts.Clear();
_selectedExternalSystems.Clear();
_selectedDbConnections.Clear();
_selectedNotificationLists.Clear();
_selectedSmtpConfigs.Clear();
_selectedApiKeys.Clear();
_selectedApiMethods.Clear();
_filter = string.Empty;
_includeDependencies = true;
_resolved = null;
_passphrase = string.Empty;
_passphraseConfirm = string.Empty;
_exportUnencrypted = false;
_showUnencryptedConfirm = false;
_downloadFilename = null;
_downloadSize = 0;
_downloadSha256 = null;
_downloadError = null;
}
// ---- Flat-list filter helpers (search box reuses TemplateFolderTree.Filter for the tree) ----
private bool MatchesFilter(string name) =>
string.IsNullOrWhiteSpace(_filter)
|| name.Contains(_filter, StringComparison.OrdinalIgnoreCase);
private static void Toggle(HashSet<int> set, int id, bool value)
{
if (value) set.Add(id);
else set.Remove(id);
}
// ---- Step 2 grouping helpers ----
/// <summary>
/// Items that are in <paramref name="all"/> but NOT in <paramref name="seed"/> —
/// the auto-included dependencies the resolver pulled in for the user.
/// </summary>
/// <typeparam name="T">The element type of the artifact list.</typeparam>
/// <param name="all">The full resolved list including both seed and auto-included items.</param>
/// <param name="seed">The set of explicitly selected item ids.</param>
/// <param name="idOf">Function that extracts the integer id from an item.</param>
internal static IReadOnlyList<T> AutoIncluded<T>(IReadOnlyList<T> all, IReadOnlyCollection<int> seed, Func<T, int> idOf)
{
return all.Where(x => !seed.Contains(idOf(x))).ToList();
}
}
@@ -0,0 +1,439 @@
@page "/design/transport/import"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Options
@using ZB.MOM.WW.ScadaBridge.Transport
@using ZB.MOM.WW.ScadaBridge.Transport.Import
@using System.Security.Cryptography
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@*
TransportImport wizard (Component #24, Task T22).
A 5-step linear wizard:
Step 1 — Upload : InputFile + manifest summary; LoadAsync without passphrase first.
Step 2 — Passphrase : only shown for encrypted bundles; 3-strike lockout.
Step 3 — Diff : conflict resolution (Add/Overwrite/Skip/Rename) per ImportPreviewItem.
Step 4 — Confirm : type-the-environment-name guard.
Step 5 — Result : ApplyAsync result + audit drilldown link.
The page is Admin-only — Import touches central configuration globally.
*@
<div class="container-fluid mt-3">
<h4 class="mb-3">Import Bundle</h4>
@* Step indicator — five numbered pills, mirrors TransportExport. *@
<nav aria-label="Import wizard steps" class="mb-4">
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
<li class="@StepClass(ImportWizardStep.Upload)">
<span class="badge rounded-pill me-1">1</span> Upload
</li>
<li class="@StepClass(ImportWizardStep.Passphrase)">
<span class="badge rounded-pill me-1">2</span> Passphrase
</li>
<li class="@StepClass(ImportWizardStep.Diff)">
<span class="badge rounded-pill me-1">3</span> Diff
</li>
<li class="@StepClass(ImportWizardStep.Confirm)">
<span class="badge rounded-pill me-1">4</span> Confirm
</li>
<li class="@StepClass(ImportWizardStep.Result)">
<span class="badge rounded-pill me-1">5</span> Result
</li>
</ol>
</nav>
@if (_errorMessage != null)
{
<div class="alert alert-danger" data-testid="error-message">@_errorMessage</div>
}
@switch (_step)
{
case ImportWizardStep.Upload:
@RenderStepUpload();
break;
case ImportWizardStep.Passphrase:
@RenderStepPassphrase();
break;
case ImportWizardStep.Diff:
@RenderStepDiff();
break;
case ImportWizardStep.Confirm:
@RenderStepConfirm();
break;
case ImportWizardStep.Result:
@RenderStepResult();
break;
}
</div>
@code {
private string StepClass(ImportWizardStep s) =>
s == _step ? "fw-semibold text-primary"
: (int)s < (int)_step ? "text-success"
: "text-muted";
// ============================================================
// Step 1 — Upload
// ============================================================
private RenderFragment RenderStepUpload() => __builder =>
{
<div>
<p class="text-body-secondary">
Select a <code>.scadabundle</code> file produced by an exporter on this
or another cluster. The bundle's manifest will be validated immediately;
encrypted bundles will prompt for a passphrase on the next step.
</p>
<div class="mb-3">
<label for="bundle-input" class="form-label">Bundle file</label>
<InputFile id="bundle-input" OnChange="OnFileSelectedAsync"
class="form-control" accept=".scadabundle,application/zip" />
<div class="form-text">
Maximum bundle size: @Options.Value.MaxBundleSizeMb MB.
</div>
</div>
@if (_uploadInProgress)
{
<div class="text-muted small fst-italic">Reading bundle…</div>
}
@if (_bundleTempPath is not null && _errorMessage is null)
{
@if (_session is not null)
{
<dl class="row small mt-3" data-testid="manifest-summary">
<dt class="col-sm-3">Source environment</dt>
<dd class="col-sm-9"><code>@_session.Manifest.SourceEnvironment</code></dd>
<dt class="col-sm-3">Exported by</dt>
<dd class="col-sm-9">@_session.Manifest.ExportedBy</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">@_session.Manifest.CreatedAtUtc.ToString("u")</dd>
<dt class="col-sm-3">Content count</dt>
<dd class="col-sm-9">@_session.Manifest.Contents.Count items</dd>
<dt class="col-sm-3">SHA-256</dt>
<dd class="col-sm-9"><code class="small">@_session.Manifest.ContentHash</code></dd>
<dt class="col-sm-3">Encryption</dt>
<dd class="col-sm-9">
@if (_session.Manifest.Encryption is null)
{
<span class="text-warning">Unencrypted</span>
}
else
{
<span class="text-success">@_session.Manifest.Encryption.Algorithm</span>
}
</dd>
</dl>
}
else
{
<div class="alert alert-info mt-3" data-testid="encrypted-bundle-notice">
<strong>Encrypted bundle uploaded.</strong>
Click <strong>Next</strong> to enter the passphrase.
</div>
}
<div class="d-flex justify-content-end mt-3">
<button class="btn btn-primary" @onclick="GoFromUploadAsync">Next</button>
</div>
}
</div>
};
// ============================================================
// Step 2 — Passphrase
// ============================================================
private RenderFragment RenderStepPassphrase() => __builder =>
{
var maxAttempts = Options.Value.MaxUnlockAttemptsPerSession;
var attemptsLeft = Math.Max(0, maxAttempts - _failedUnlockAttempts);
<div>
<p class="text-body-secondary">
This bundle is encrypted. Enter the passphrase that was used to
produce it. You have @attemptsLeft of @maxAttempts attempts before
the upload must be restarted.
</p>
<div class="mb-3">
<label for="import-passphrase" class="form-label">Passphrase</label>
<input id="import-passphrase" type="password" class="form-control"
autocomplete="current-password"
@bind="_passphrase" @bind:event="oninput" />
</div>
@if (_failedUnlockAttempts > 0)
{
<div class="alert alert-warning small" data-testid="unlock-attempts">
Failed unlock attempts: @_failedUnlockAttempts of @maxAttempts.
</div>
}
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
<button class="btn btn-primary"
disabled="@(string.IsNullOrEmpty(_passphrase) || _uploadInProgress)"
@onclick="SubmitPassphraseAsync">
@(_uploadInProgress ? "Unlocking…" : "Unlock")
</button>
</div>
</div>
};
// ============================================================
// Step 3 — Diff & resolve conflicts
// ============================================================
private RenderFragment RenderStepDiff() => __builder =>
{
if (_preview is null || _resolutions is null)
{
<div class="alert alert-warning">No preview available — please go back and re-upload.</div>
return;
}
var (adds, overs, skips, renames, blockers) = CountResolutions();
var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
<div>
<p class="text-body-secondary">
Review each artifact in the bundle and choose how it should be applied
to this environment. Identical items are skipped automatically; new
items default to Add; modified items require an explicit choice.
</p>
<div class="mb-3 d-flex flex-wrap gap-2 align-items-center" data-testid="bulk-actions">
<span class="small text-body-secondary">Apply to all modified:</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Skip)">Skip</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Overwrite)">Overwrite</button>
</div>
<div class="table-responsive" style="max-height: 480px; overflow-y: auto;">
<table class="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Status</th>
<th>Existing</th>
<th>Incoming</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@foreach (var item in _preview.Items)
{
var key = (item.EntityType, item.Name);
var current = _resolutions[key];
<tr data-testid="diff-row">
<td><span class="badge bg-secondary">@item.EntityType</span></td>
<td>@item.Name</td>
<td>@RenderKindBadge(item)</td>
<td>@(item.ExistingVersion?.ToString() ?? "—")</td>
<td>@(item.IncomingVersion?.ToString() ?? "—")</td>
<td>@RenderResolutionControls(item, current)</td>
</tr>
@if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson))
{
<tr>
<td colspan="6">
<details>
<summary class="small">Field diff</summary>
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
</details>
</td>
</tr>
}
@if (item.Kind == ConflictKind.Blocker && !string.IsNullOrEmpty(item.BlockerReason))
{
<tr>
<td colspan="6">
<div class="alert alert-danger small mb-0">@item.BlockerReason</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
<div>
<span class="me-3 small text-body-secondary" data-testid="diff-summary">
@adds add · @overs overwrite · @skips skip · @renames rename · @blockers blocker
</span>
<button class="btn btn-primary"
disabled="@hasBlockers"
@onclick="GoToConfirm">
Next
</button>
</div>
</div>
</div>
};
private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder =>
{
var (cls, label) = item.Kind switch
{
ConflictKind.Identical => ("bg-secondary", "Identical"),
ConflictKind.Modified => ("bg-warning text-dark", "Modified"),
ConflictKind.New => ("bg-success", "New"),
ConflictKind.Blocker => ("bg-danger", "Blocker"),
_ => ("bg-light text-dark", item.Kind.ToString()),
};
<span class="badge @cls">@label</span>
};
private RenderFragment RenderResolutionControls(ImportPreviewItem item, ImportResolution current) => __builder =>
{
// Identical → forced Skip; New → forced Add; Blocker → no actions.
if (item.Kind == ConflictKind.Identical)
{
<span class="text-muted small">Skip</span>
return;
}
if (item.Kind == ConflictKind.New)
{
<span class="text-muted small">Add</span>
return;
}
if (item.Kind == ConflictKind.Blocker)
{
<span class="text-muted small">—</span>
return;
}
var key = (item.EntityType, item.Name);
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var action in new[] { ResolutionAction.Overwrite, ResolutionAction.Skip, ResolutionAction.Rename })
{
var inputId = $"res-{item.EntityType}-{item.Name}-{action}";
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="radio"
id="@inputId"
name="@($"res-{item.EntityType}-{item.Name}")"
checked="@(current.Action == action)"
@onchange="() => SetResolution(key, action)" />
<label class="form-check-label small" for="@inputId">@action</label>
</div>
}
@if (current.Action == ResolutionAction.Rename)
{
<input type="text" class="form-control form-control-sm"
style="max-width: 14rem;"
placeholder="New name"
value="@(current.RenameTo ?? string.Empty)"
@onchange="e => SetRenameTo(key, e.Value?.ToString())" />
}
</div>
};
// ============================================================
// Step 4 — Confirm
// ============================================================
private RenderFragment RenderStepConfirm() => __builder =>
{
if (_session is null)
{
<div class="alert alert-warning">No bundle session — please re-upload.</div>
return;
}
var (adds, overs, skips, renames, _) = CountResolutions();
var changeCount = adds + overs + renames;
<div>
<p class="text-body-secondary">
You are about to apply <strong>@changeCount</strong> change(s)
to this environment (@adds add · @overs overwrite · @skips skip · @renames rename).
</p>
<div class="alert alert-info small">
Affected instances will become stale and require redeployment via the
<a href="/deployment/deployments">Deployments</a> page.
</div>
<div class="mb-3">
<label for="confirm-env" class="form-label">
Type the source environment name <code>@_session.Manifest.SourceEnvironment</code> to confirm:
</label>
<input id="confirm-env" type="text" class="form-control"
@bind="_confirmEnvironmentText" @bind:event="oninput" />
</div>
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
<button class="btn btn-danger"
disabled="@(_confirmEnvironmentText != _session.Manifest.SourceEnvironment || _applyInProgress)"
@onclick="ApplyAsync">
@(_applyInProgress ? "Applying…" : "Apply Import")
</button>
</div>
</div>
};
// ============================================================
// Step 5 — Result
// ============================================================
private RenderFragment RenderStepResult() => __builder =>
{
<div>
@if (_validationErrors is not null && _validationErrors.Count > 0)
{
<div class="alert alert-danger" data-testid="validation-errors">
<strong>Bundle semantic validation failed.</strong>
<ul class="mb-0">
@foreach (var err in _validationErrors)
{
<li>@err</li>
}
</ul>
</div>
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
}
else if (_result is not null)
{
<div class="alert alert-success" data-testid="result-summary">
<strong>Import complete.</strong>
@_result.Added added · @_result.Overwritten overwritten ·
@_result.Skipped skipped · @_result.Renamed renamed.
</div>
<dl class="row small">
<dt class="col-sm-3">Bundle Import Id</dt>
<dd class="col-sm-9"><code>@_result.BundleImportId</code></dd>
</dl>
<div class="d-flex gap-3">
<a class="btn btn-outline-primary" href="/deployment/deployments">
View on Deployments →
</a>
<a class="btn btn-outline-secondary"
href="@($"/audit/configuration?bundleImportId={_result.BundleImportId}")">
Audit trail →
</a>
</div>
}
else
{
<div class="alert alert-danger">
Import failed. Please re-upload the bundle.
</div>
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
}
</div>
};
}
@@ -0,0 +1,608 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design;
/// <summary>
/// Code-behind for the TransportImport wizard (Transport feature, Task T22).
///
/// Five-step state machine:
/// <list type="number">
/// <item><see cref="ImportWizardStep.Upload"/> — read bundle bytes, attempt
/// a passphrase-less <see cref="IBundleImporter.LoadAsync"/>; if the
/// bundle is encrypted, advance to Step 2 without yet opening a session.</item>
/// <item><see cref="ImportWizardStep.Passphrase"/> — collect the passphrase
/// and retry LoadAsync; 3-strike lockout per the configured
/// <see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>.</item>
/// <item><see cref="ImportWizardStep.Diff"/> — render <see cref="ImportPreview"/>
/// items, collect <see cref="ImportResolution"/> per Modified item; Apply
/// is blocked while any <see cref="ConflictKind.Blocker"/> remains.</item>
/// <item><see cref="ImportWizardStep.Confirm"/> — type-the-environment-name
/// guard prevents accidental cross-cluster overwrites.</item>
/// <item><see cref="ImportWizardStep.Result"/> — render Apply result + audit
/// drill-in link; on <see cref="SemanticValidationException"/>, surface
/// the error list and allow returning to Step 3.</item>
/// </list>
///
/// The page is gated on <c>RequireAdmin</c> — Import touches central configuration
/// globally and must not be available to Design-only or Deployment-only users.
///
/// Cached bundle bytes: because <see cref="IBundleImporter.LoadAsync"/> currently
/// peeks the manifest by attempting decryption, encrypted bundles require two
/// LoadAsync invocations. CentralUI-031: we previously cached the raw bytes in a
/// <c>byte[] _bundleBytes</c> field, which buffered the full upload (default cap
/// 100 MB) in the component's per-circuit state — multiplied across concurrent
/// operator sessions, that produced real central-node memory pressure. The
/// bytes are now streamed once to a per-session temp file under
/// <c>Path.GetTempPath()/scadabridge-transport-staging/</c> and only the path is
/// retained on the component. The file is deleted on Back-to-Upload / Reset /
/// successful Apply / component Dispose, so an abandoned wizard does not leak
/// staged bundle plaintext beyond circuit teardown.
/// </summary>
public partial class TransportImport : ComponentBase, IDisposable
{
public enum ImportWizardStep
{
Upload = 1,
Passphrase = 2,
Diff = 3,
Confirm = 4,
Result = 5,
}
// ---- Injected services ----
[Inject] private IBundleImporter BundleImporter { get; set; } = default!;
[Inject] private NavigationManager Nav { get; set; } = default!;
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
[Inject] private IOptions<TransportOptions> Options { get; set; } = default!;
[Inject] private IAuditService AuditService { get; set; } = default!;
[Inject] private ScadaBridgeDbContext DbContext { get; set; } = default!;
// ---- Wizard state ----
private ImportWizardStep _step = ImportWizardStep.Upload;
private string? _errorMessage;
// ---- Session + cached bundle path ----
// CentralUI-031: the upload is streamed to a per-session temp file and only
// the path is retained on the component, so we don't hold an entire bundle
// (up to MaxBundleSizeMb, default 100 MB) in per-circuit memory across the
// wizard's lifetime. The file is deleted on every wizard reset path and on
// component disposal so an abandoned wizard cannot leak staged plaintext
// beyond circuit teardown.
private string? _bundleTempPath;
private BundleSession? _session;
private bool _uploadInProgress;
// Staging directory for in-flight bundle uploads. Lives under the system
// temp directory rather than wwwroot/ because the file is never served to
// a browser — it is only read by the in-process IBundleImporter.
private static readonly string StagingDir =
Path.Combine(Path.GetTempPath(), "scadabridge-transport-staging");
// ---- Step 2: passphrase ----
private string _passphrase = string.Empty;
private int _failedUnlockAttempts;
// ---- Step 3: preview + resolutions ----
private ImportPreview? _preview;
// Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup.
private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions;
// ---- Step 4: confirm ----
private string _confirmEnvironmentText = string.Empty;
// ---- Step 5: apply result ----
private bool _applyInProgress;
private ImportResult? _result;
private IReadOnlyList<string>? _validationErrors;
// ============================================================
// Step 1 — Upload
// ============================================================
/// <summary>
/// Buffers the selected file, enforces the configured size cap, then calls
/// <see cref="IBundleImporter.LoadAsync"/> with no passphrase to peek the
/// manifest. Encrypted bundles surface as <see cref="ArgumentException"/>,
/// which we catch and use to advance to Step 2 — the session is opened on
/// the second LoadAsync call once the passphrase is provided.
/// </summary>
private async Task OnFileSelectedAsync(InputFileChangeEventArgs e)
{
_errorMessage = null;
_uploadInProgress = true;
_session = null;
DeleteBundleTempFile();
try
{
var maxBytes = Options.Value.MaxBundleSizeMb * 1024L * 1024L;
if (e.File.Size > maxBytes)
{
_errorMessage = $"Bundle exceeds the maximum allowed size of {Options.Value.MaxBundleSizeMb} MB.";
return;
}
// CentralUI-031: stream the upload directly to a per-session temp
// file so the central node's working set is bounded by the
// FileStream buffer (~80 KB) rather than the full bundle bytes.
// OpenReadStream's MaxAllowedSize defaults to 500_000 bytes — bump
// it to the configured cap so the read doesn't throw before we get
// to the importer's own length check.
Directory.CreateDirectory(StagingDir);
_bundleTempPath = Path.Combine(StagingDir, $"{Guid.NewGuid():N}.scadabundle");
using (var fileStream = e.File.OpenReadStream(maxBytes))
await using (var dest = new FileStream(
_bundleTempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
{
await fileStream.CopyToAsync(dest);
}
await TryLoadAsync(passphrase: null);
}
catch (ArgumentException)
{
// Encrypted bundle, no passphrase yet — expected. The wizard
// advances to the passphrase step when the user clicks Next.
}
catch (Exception ex)
{
_errorMessage = $"Failed to read bundle: {ex.Message}";
}
finally
{
_uploadInProgress = false;
}
}
/// <summary>
/// Attempts to open a <see cref="BundleSession"/> from the cached bytes with
/// the given passphrase. On <see cref="ArgumentException"/> (encrypted bundle
/// with no passphrase) leaves the wizard's step caller to advance to the
/// passphrase step. Wrong-passphrase failures surface as
/// <see cref="CryptographicException"/> and are counted by the caller.
/// </summary>
private async Task TryLoadAsync(string? passphrase)
{
if (_bundleTempPath is null || !File.Exists(_bundleTempPath))
{
_errorMessage = "No bundle staged — please re-select the file.";
return;
}
try
{
// CentralUI-031: read the staged bundle straight off disk; the
// importer's LoadAsync only walks the stream forward, so a plain
// FileStream is sufficient (no need to buffer it back into memory).
using var stream = new FileStream(
_bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 81920, useAsync: true);
_session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
_errorMessage = null;
}
catch (ArgumentException)
{
// Encrypted bundle, no passphrase supplied — caller advances to Step 2.
// We deliberately do NOT set _errorMessage here; the page surfaces
// an empty Step-2 prompt instead.
_session = null;
throw;
}
catch (CryptographicException)
{
// Wrong passphrase — bubble so the caller can increment the counter.
_session = null;
throw;
}
catch (InvalidDataException ex)
{
_session = null;
_errorMessage = $"Bundle is invalid: {ex.Message}";
}
catch (NotSupportedException ex)
{
_session = null;
_errorMessage = $"Bundle format unsupported: {ex.Message}";
}
catch (InvalidOperationException ex)
{
_session = null;
_errorMessage = ex.Message;
}
}
/// <summary>
/// Advances from Step 1 to either the passphrase step (encrypted bundle) or
/// straight to the diff step (unencrypted bundle). For encrypted bundles
/// LoadAsync was already attempted with <c>null</c> and threw
/// <see cref="ArgumentException"/>, so <c>_session</c> is null and we move
/// to Step 2. For unencrypted bundles <c>_session</c> is already populated;
/// jump directly to Step 3.
/// </summary>
private async Task GoFromUploadAsync()
{
if (_session is null)
{
// Peek the manifest to find out if it's encrypted. We re-call LoadAsync
// with null passphrase; for encrypted bundles this throws
// ArgumentException → advance to Step 2.
try
{
await TryLoadAsync(passphrase: null);
}
catch (ArgumentException)
{
_step = ImportWizardStep.Passphrase;
return;
}
catch (CryptographicException)
{
_errorMessage = "Bundle could not be decrypted.";
return;
}
}
if (_session is null)
{
// Some other error already surfaced via _errorMessage.
return;
}
if (_session.Manifest.Encryption is not null)
{
_step = ImportWizardStep.Passphrase;
}
else
{
await LoadPreviewAndAdvanceAsync();
}
}
// ============================================================
// Step 2 — Passphrase
// ============================================================
/// <summary>
/// Submits the entered passphrase.
/// <para>
/// T-003: lockout enforcement is now server-side and keyed by the bundle's
/// content hash. <see cref="CryptographicException"/> means "wrong passphrase,
/// try again"; a <see cref="BundleLockedException"/> means the importer has
/// observed enough failures against this bundle to lock it (the count is
/// shared across tabs / CLI / circuits). The Razor counter is kept ONLY for
/// display ("3 of N attempts used") — it is no longer the source of truth.
/// </para>
/// </summary>
private async Task SubmitPassphraseAsync()
{
if (string.IsNullOrEmpty(_passphrase))
{
return;
}
_uploadInProgress = true;
try
{
await TryLoadAsync(_passphrase);
if (_session is not null)
{
_failedUnlockAttempts = 0;
_passphrase = string.Empty;
await LoadPreviewAndAdvanceAsync();
}
}
catch (BundleLockedException ex)
{
// T-003: server-side lockout reached. Emit a final audit row so the
// lockout is visible in the audit log, reset the wizard, and surface
// the typed message verbatim.
_passphrase = string.Empty;
_failedUnlockAttempts = ex.FailedAttempts;
await EmitUnlockFailedAuditRowAsync(ex.BundleContentHash, ex.FailedAttempts, ex.Message);
_errorMessage = ex.Message;
ResetSessionState();
_step = ImportWizardStep.Upload;
}
catch (CryptographicException ex)
{
_failedUnlockAttempts++;
_passphrase = string.Empty;
var entityId = _session?.Manifest.ContentHash ?? "<no-session>";
await EmitUnlockFailedAuditRowAsync(entityId, _failedUnlockAttempts, ex.Message);
// The server tracks the authoritative counter; the local count is
// kept in sync for the Razor display only.
if (_failedUnlockAttempts >= Options.Value.MaxUnlockAttemptsPerSession)
{
_errorMessage =
$"Too many failed unlock attempts ({_failedUnlockAttempts}). "
+ "Please re-upload the bundle.";
ResetSessionState();
_step = ImportWizardStep.Upload;
}
else
{
_errorMessage = "Wrong passphrase. Please try again.";
}
}
catch (ArgumentException)
{
_errorMessage = "Passphrase required.";
}
finally
{
_uploadInProgress = false;
}
}
/// <summary>
/// T-003: best-effort audit row for a wrong-passphrase attempt. Audit failure
/// must never abort the user-facing action — same defensive pattern as the
/// original page used.
/// </summary>
private async Task EmitUnlockFailedAuditRowAsync(string entityId, int attemptNumber, string reason)
{
try
{
var user = await Auth.GetCurrentUsernameAsync();
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
await AuditService.LogAsync(
user: user,
action: "BundleImportUnlockFailed",
entityType: "Bundle",
entityId: entityId,
entityName: entityName,
afterState: new
{
AttemptNumber = attemptNumber,
Reason = reason,
},
cancellationToken: CancellationToken.None);
await DbContext.SaveChangesAsync();
}
catch
{
// Audit failure is non-fatal — swallow and continue.
}
}
private void BackToUpload()
{
_step = ImportWizardStep.Upload;
_errorMessage = null;
}
// ============================================================
// Step 3 — Diff & resolve conflicts
// ============================================================
private async Task LoadPreviewAndAdvanceAsync()
{
if (_session is null) return;
try
{
_preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None);
_resolutions = BuildDefaultResolutions(_preview);
_step = ImportWizardStep.Diff;
}
catch (Exception ex)
{
_errorMessage = $"Failed to build import preview: {ex.Message}";
}
}
/// <summary>
/// Builds the default resolution per preview item:
/// <list type="bullet">
/// <item><see cref="ConflictKind.Identical"/> → <see cref="ResolutionAction.Skip"/></item>
/// <item><see cref="ConflictKind.New"/> → <see cref="ResolutionAction.Add"/></item>
/// <item><see cref="ConflictKind.Modified"/> → <see cref="ResolutionAction.Overwrite"/></item>
/// <item><see cref="ConflictKind.Blocker"/> → <see cref="ResolutionAction.Skip"/> (UI disables Apply anyway)</item>
/// </list>
/// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned.
/// </summary>
/// <param name="preview">The import preview containing all conflict items to map.</param>
/// <returns>A dictionary keyed by (EntityType, Name) with default resolution actions populated.</returns>
internal static Dictionary<(string EntityType, string Name), ImportResolution> BuildDefaultResolutions(
ImportPreview preview)
{
var map = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
{
ConflictKind.Identical => ResolutionAction.Skip,
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Modified => ResolutionAction.Overwrite,
ConflictKind.Blocker => ResolutionAction.Skip,
_ => ResolutionAction.Skip,
};
map[(item.EntityType, item.Name)] = new ImportResolution(
item.EntityType, item.Name, action, RenameTo: null);
}
return map;
}
private void SetResolution((string EntityType, string Name) key, ResolutionAction action)
{
if (_resolutions is null) return;
var existing = _resolutions[key];
_resolutions[key] = existing with { Action = action };
}
private void SetRenameTo((string EntityType, string Name) key, string? renameTo)
{
if (_resolutions is null) return;
var existing = _resolutions[key];
_resolutions[key] = existing with { RenameTo = renameTo };
}
private void BulkSet(ResolutionAction action)
{
if (_resolutions is null || _preview is null) return;
foreach (var item in _preview.Items)
{
if (item.Kind != ConflictKind.Modified) continue;
var key = (item.EntityType, item.Name);
_resolutions[key] = _resolutions[key] with { Action = action };
}
}
private (int Adds, int Overs, int Skips, int Renames, int Blockers) CountResolutions()
{
if (_preview is null || _resolutions is null) return (0, 0, 0, 0, 0);
var adds = 0;
var overs = 0;
var skips = 0;
var renames = 0;
var blockers = 0;
foreach (var item in _preview.Items)
{
if (item.Kind == ConflictKind.Blocker)
{
blockers++;
continue;
}
var action = _resolutions[(item.EntityType, item.Name)].Action;
switch (action)
{
case ResolutionAction.Add: adds++; break;
case ResolutionAction.Overwrite: overs++; break;
case ResolutionAction.Skip: skips++; break;
case ResolutionAction.Rename: renames++; break;
}
}
return (adds, overs, skips, renames, blockers);
}
private void GoToConfirm()
{
if (_preview is null) return;
if (_preview.Items.Any(i => i.Kind == ConflictKind.Blocker))
{
_errorMessage = "Cannot proceed while blockers exist — resolve or remove blocker rows first.";
return;
}
_confirmEnvironmentText = string.Empty;
_step = ImportWizardStep.Confirm;
}
private void BackToDiff()
{
_step = ImportWizardStep.Diff;
_errorMessage = null;
_validationErrors = null;
_result = null;
}
// ============================================================
// Step 4 + 5 — Confirm & Apply
// ============================================================
/// <summary>
/// Invokes <see cref="IBundleImporter.ApplyAsync"/> with the collected
/// resolutions and the authenticated user identity. Distinguishes
/// <see cref="SemanticValidationException"/> (recoverable — surface the
/// error list and let the operator return to Step 3) from generic
/// exceptions (display generic error + force re-upload).
/// </summary>
private async Task ApplyAsync()
{
if (_session is null || _resolutions is null) return;
if (_confirmEnvironmentText != _session.Manifest.SourceEnvironment) return;
_applyInProgress = true;
_errorMessage = null;
_validationErrors = null;
_result = null;
try
{
var user = await Auth.GetCurrentUsernameAsync();
_result = await BundleImporter.ApplyAsync(
_session.SessionId,
_resolutions.Values.ToList(),
user,
CancellationToken.None);
_step = ImportWizardStep.Result;
}
catch (SemanticValidationException ex)
{
_validationErrors = ex.Errors;
_step = ImportWizardStep.Result;
}
catch (Exception ex)
{
_errorMessage = $"Import failed: {ex.Message}. Please re-upload the bundle.";
_step = ImportWizardStep.Result;
}
finally
{
_applyInProgress = false;
}
}
// ============================================================
// Reset helpers
// ============================================================
private void ResetSessionState()
{
_session = null;
DeleteBundleTempFile();
_preview = null;
_resolutions = null;
_passphrase = string.Empty;
_confirmEnvironmentText = string.Empty;
_result = null;
_validationErrors = null;
}
/// <summary>
/// CentralUI-031: deletes the staged bundle temp file if any. Swallows IO
/// failures — an undeletable temp file is best-effort cleanup and must not
/// block the wizard.
/// </summary>
private void DeleteBundleTempFile()
{
var path = _bundleTempPath;
_bundleTempPath = null;
if (path is null) return;
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Another handle may still be open (e.g. an in-flight LoadAsync
// read). Leave the file behind; the OS temp dir is reaped on its
// own schedule. Audit-failure-style: never block the user-facing
// action.
}
catch (UnauthorizedAccessException)
{
// Same rationale.
}
}
/// <summary>
/// CentralUI-031: ensures the staged temp file does not survive circuit
/// teardown. Blazor invokes Dispose when the user navigates away or the
/// circuit ends, so an abandoned wizard cleans up automatically.
/// </summary>
public void Dispose()
{
DeleteBundleTempFile();
}
}
@@ -0,0 +1,36 @@
@page "/login"
@layout LoginLayout
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
<div class="d-flex align-items-center justify-content-center min-vh-100">
<div class="card shadow-sm" style="max-width: 400px; width: 100%;">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">ScadaBridge</h4>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger py-2" role="alert">@ErrorMessage</div>
}
<form method="post" action="/auth/login" data-enhance="false">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username"
required autocomplete="username" autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
required autocomplete="current-password" />
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
</div>
</div>
</div>
@code {
[SupplyParameterFromQuery(Name = "error")]
public string? ErrorMessage { get; set; }
}
@@ -0,0 +1,323 @@
@page "/monitoring/event-logs"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery
@using ZB.MOM.WW.ScadaBridge.Communication
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject CommunicationService CommunicationService
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Event Logs</h4>
<button class="btn btn-outline-secondary btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#event-logs-filters"
aria-expanded="true"
aria-controls="event-logs-filters">
Filter options (@ActiveFilterCount active)
</button>
</div>
<ToastNotification @ref="_toast" />
<div class="collapse show" id="event-logs-filters">
<div class="row mb-3 g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small" for="filter-site">Site</label>
<select id="filter-site" class="form-select form-select-sm" aria-label="Site" @bind="_selectedSiteId">
<option value="">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-event-type">Event Type</label>
<input id="filter-event-type"
type="text"
class="form-control form-control-sm"
aria-label="Event type"
@bind="_filterEventType"
placeholder="e.g. ScriptError" />
</div>
<div class="col-md-1">
<label class="form-label small" for="filter-severity">Severity</label>
<select id="filter-severity"
class="form-select form-select-sm"
aria-label="Severity"
@bind="_filterSeverity">
<option value="">All</option>
<option>Info</option>
<option>Warning</option>
<option>Error</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-from">From</label>
<input id="filter-from"
type="datetime-local"
class="form-control form-control-sm"
aria-label="From timestamp"
@bind="_filterFrom" />
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-to">To</label>
<input id="filter-to"
type="datetime-local"
class="form-control form-control-sm"
aria-label="To timestamp"
@bind="_filterTo" />
</div>
<div class="col-md-1">
<label class="form-label small" for="filter-keyword">Message contains</label>
<input id="filter-keyword"
type="text"
class="form-control form-control-sm"
aria-label="Message contains"
@bind="_filterKeyword" />
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-instance">Instance</label>
<input id="filter-instance"
type="text"
class="form-control form-control-sm"
aria-label="Instance name"
@bind="_filterInstanceName"
placeholder="Instance name" />
</div>
<div class="col-md-12 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
</div>
</div>
</div>
@if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
@if (_entries != null)
{
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th style="width: 1%;"></th>
<th>Timestamp</th>
<th>Type</th>
<th>Severity</th>
<th>Instance</th>
<th>Source</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@if (_entries.Count == 0)
{
<tr><td colspan="7" class="text-muted text-center">No events found.</td></tr>
}
@for (int i = 0; i < _entries.Count; i++)
{
var idx = i;
var entry = _entries[idx];
var rowClass = entry.Severity == "Error" ? "table-danger"
: entry.Severity == "Warning" ? "table-warning"
: "";
var expanded = _expandedRows.Contains(idx);
<tr class="@rowClass">
<td>
<button class="btn btn-link btn-sm p-0"
@onclick="() => ToggleRow(idx)"
aria-label="@(expanded ? "Hide full message" : "View full message")">
@(expanded ? "Hide" : "View")
</button>
</td>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small">@entry.EventType</td>
<td>
<span class="badge @GetSeverityBadge(entry.Severity)" aria-label="Severity: @entry.Severity">
@SeverityGlyph(entry.Severity) @entry.Severity
</span>
</td>
<td class="small">@(entry.InstanceId ?? "—")</td>
<td class="small">@entry.Source</td>
<td class="small text-truncate" style="max-width: 380px;">@entry.Message</td>
</tr>
@if (expanded)
{
<tr class="@rowClass">
<td colspan="7">
<pre class="small mb-0">@entry.Message</pre>
</td>
</tr>
}
}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Showing @_entries.Count entries</span>
<div>
@if (_hasMore)
{
<button class="btn btn-outline-primary btn-sm" @onclick="LoadMore" disabled="@_searching">Load more</button>
}
else if (_entries.Count > 0)
{
<span class="text-muted small">End of results</span>
}
</div>
</div>
}
</div>
@code {
private List<Site> _sites = new();
private string _selectedSiteId = string.Empty;
private string? _filterEventType;
private string _filterSeverity = string.Empty;
private DateTime? _filterFrom;
private DateTime? _filterTo;
private string? _filterKeyword;
private string? _filterInstanceName;
private List<EventLogEntry>? _entries;
private bool _hasMore;
private long? _continuationToken;
private bool _searching;
private string? _errorMessage;
private ToastNotification _toast = default!;
private readonly HashSet<int> _expandedRows = new();
private int ActiveFilterCount
{
get
{
var n = 0;
if (!string.IsNullOrEmpty(_selectedSiteId)) n++;
if (!string.IsNullOrWhiteSpace(_filterEventType)) n++;
if (!string.IsNullOrEmpty(_filterSeverity)) n++;
if (_filterFrom.HasValue) n++;
if (_filterTo.HasValue) n++;
if (!string.IsNullOrWhiteSpace(_filterKeyword)) n++;
if (!string.IsNullOrWhiteSpace(_filterInstanceName)) n++;
return n;
}
}
protected override async Task OnInitializedAsync()
{
// Site scoping (CentralUI-002): a scoped Deployment user may only query
// event logs for the sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
}
// _sites is already filtered, so membership IS the scope check.
private bool SelectedSiteIsPermitted =>
!string.IsNullOrEmpty(_selectedSiteId)
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
private async Task Search()
{
_entries = new();
_continuationToken = null;
_expandedRows.Clear();
await FetchPage();
}
private async Task LoadMore() => await FetchPage();
private void ToggleRow(int idx)
{
if (!_expandedRows.Add(idx))
{
_expandedRows.Remove(idx);
}
}
private async Task FetchPage()
{
_searching = true;
_errorMessage = null;
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
// filtered, but the selection must not be trusted on its own.
if (!SelectedSiteIsPermitted)
{
_errorMessage = "You are not permitted to view event logs for that site.";
_searching = false;
return;
}
try
{
var request = new EventLogQueryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
SiteId: _selectedSiteId,
// CentralUI-027: <input type="datetime-local"> binds with DateTimeKind.Unspecified
// — the value is the operator's browser-local wall-clock. Tag it Local and
// convert to UTC; the prior code labelled the local value as UTC, silently
// shifting the query window by the operator's UTC offset.
From: LocalInputToUtc(_filterFrom),
To: LocalInputToUtc(_filterTo),
EventType: string.IsNullOrWhiteSpace(_filterEventType) ? null : _filterEventType.Trim(),
Severity: string.IsNullOrWhiteSpace(_filterSeverity) ? null : _filterSeverity,
InstanceId: string.IsNullOrWhiteSpace(_filterInstanceName) ? null : _filterInstanceName.Trim(),
KeywordFilter: string.IsNullOrWhiteSpace(_filterKeyword) ? null : _filterKeyword.Trim(),
ContinuationToken: _continuationToken,
PageSize: 50,
Timestamp: DateTimeOffset.UtcNow);
var response = await CommunicationService.QueryEventLogsAsync(_selectedSiteId, request);
if (response.Success)
{
_entries ??= new();
_entries.AddRange(response.Entries);
_hasMore = response.HasMore;
_continuationToken = response.ContinuationToken;
}
else
{
_errorMessage = response.ErrorMessage ?? "Query failed.";
}
}
catch (Exception ex)
{
_errorMessage = $"Query failed: {ex.Message}";
}
_searching = false;
}
/// <summary>
/// CentralUI-027: convert a value bound from <c>&lt;input type="datetime-local"&gt;</c>
/// (DateTimeKind.Unspecified, operator's browser-local wall-clock) into UTC. Must tag
/// the value Local before <see cref="DateTime.ToUniversalTime"/> can do anything.
/// </summary>
private static DateTimeOffset? LocalInputToUtc(DateTime? value) =>
value.HasValue
? new DateTimeOffset(
DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime(),
TimeSpan.Zero)
: (DateTimeOffset?)null;
private static string GetSeverityBadge(string severity) => severity switch
{
"Error" => "bg-danger",
"Warning" => "bg-warning text-dark",
"Info" => "bg-info text-dark",
_ => "bg-secondary"
};
private static string SeverityGlyph(string severity) => severity switch
{
"Error" => "⛔",
"Warning" => "⚠",
"Info" => "",
_ => "•"
};
}
@@ -0,0 +1,517 @@
@page "/monitoring/health"
@attribute [Authorize]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.HealthMonitoring
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit
@using ZB.MOM.WW.ScadaBridge.Communication
@implements IDisposable
@inject ICentralHealthAggregator HealthAggregator
@inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
@inject IAuditLogQueryService AuditLogQueryService
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Health Dashboard</h4>
<div>
<span class="text-muted small me-2">Auto-refresh: @(_autoRefreshSeconds)s</span>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshNow">Refresh Now</button>
</div>
</div>
@* Notification Outbox headline KPIs — a central concern, shown regardless of site reports *@
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Notification Outbox</h6>
<a class="small" href="/notifications/kpis">View details &rarr;</a>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100">
<div class="card-body text-center">
<h3 class="mb-0">@OutboxTileValue(_outboxKpi.QueueDepth)</h3>
<small class="text-muted">Queue Depth</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "border-warning" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.StuckCount > 0 ? "text-warning" : "")">@OutboxTileValue(_outboxKpi.StuckCount)</h3>
<small class="text-muted">Stuck</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card h-100 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "border-danger" : "")">
<div class="card-body text-center">
<h3 class="mb-0 @(_outboxKpiAvailable && _outboxKpi.ParkedCount > 0 ? "text-danger" : "")">@OutboxTileValue(_outboxKpi.ParkedCount)</h3>
<small class="text-muted">Parked</small>
</div>
</div>
</div>
</div>
@if (!_outboxKpiAvailable && _outboxKpiError != null)
{
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
}
@* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel
(buffered / stuck / parked). Refreshed alongside the site states. *@
<SiteCallKpiTiles Snapshot="@_siteCallKpi"
IsAvailable="@_siteCallKpiAvailable"
ErrorMessage="@_siteCallKpiError" />
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
(volume / error rate / backlog). Refreshed alongside the site states. *@
<AuditKpiTiles Snapshot="@_auditKpi"
IsAvailable="@_auditKpiAvailable"
ErrorMessage="@_auditKpiError" />
@if (_siteStates.Count == 0)
{
<div class="alert alert-info">No site health reports received yet.</div>
}
else
{
@* Overview cards *@
<div class="row g-3 mb-3">
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-success h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-success">@_siteStates.Values.Count(s => s.IsOnline)</h3>
<small class="text-muted">Sites Online</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-danger h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-danger">@_siteStates.Values.Count(s => !s.IsOnline)</h3>
<small class="text-muted">Sites Offline</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12">
<div class="card border-warning h-100">
<div class="card-body text-center">
<h3 class="mb-0 text-warning">@_siteStates.Values.Count(SiteHasActiveErrors)</h3>
<small class="text-muted">Sites with active errors</small>
</div>
</div>
</div>
</div>
@* Per-site detail cards — central cluster pinned to the top, then sites alphabetically *@
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key == CentralHealthReportLoop.CentralSiteId ? 0 : 1).ThenBy(s => s.Key))
{
var isCentral = siteId == CentralHealthReportLoop.CentralSiteId;
var siteName = isCentral ? "Central Cluster" : GetSiteName(siteId);
var detailsCollapseId = $"site-details-{siteId}";
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<div>
@if (state.IsOnline)
{
<span class="badge bg-success me-2" aria-label="State: Online">@OnlineGlyph Online</span>
}
else
{
<span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
}
<strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong>
</div>
<small class="text-muted">
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
| Seq: @state.LastSequenceNumber
</small>
</div>
<div class="card-body p-3">
@if (state.LatestReport != null)
{
var report = state.LatestReport;
<div class="row g-3">
@* Column 1: Nodes *@
<div class="col-md-6">
<h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
@if (report.ClusterNodes is { Count: > 0 })
{
@foreach (var node in report.ClusterNodes)
{
<tr>
<td class="small">@node.Hostname</td>
<td>
<span class="badge @(node.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(node.IsOnline ? "Online" : "Offline")">
@(node.IsOnline ? OnlineGlyph : OfflineGlyph) @(node.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
<span class="badge @(node.Role == "Primary" ? "bg-primary" : "bg-secondary")"
aria-label="State: @node.Role">
@(node.Role == "Primary" ? PrimaryGlyph : StandbyGlyph) @node.Role
</span>
</td>
</tr>
}
}
else
{
<tr>
<td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td>
<td>
<span class="badge @(state.IsOnline ? "bg-success" : "bg-danger")"
aria-label="State: @(state.IsOnline ? "Online" : "Offline")">
@(state.IsOnline ? OnlineGlyph : OfflineGlyph) @(state.IsOnline ? "Online" : "Offline")
</span>
</td>
<td>
@{
var roleLabel = report.NodeRole == "Active" ? "Primary" : "Standby";
}
<span class="badge @(report.NodeRole == "Active" ? "bg-primary" : "bg-secondary")"
aria-label="State: @roleLabel">
@(roleLabel == "Primary" ? PrimaryGlyph : StandbyGlyph) @roleLabel
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
@* Column 2: Data Connections (collapsible) *@
<div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-conns")"
aria-expanded="false">
Data Connections (@report.DataConnectionStatuses.Count)
</button>
<div class="collapse" id="@($"{detailsCollapseId}-conns")">
@if (report.DataConnectionStatuses.Count == 0)
{
<span class="text-muted small">None</span>
}
else
{
@foreach (var (connName, health) in report.DataConnectionStatuses)
{
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
<div class="mb-2">
<div class="d-flex justify-content-between">
<strong class="small">@connName</strong>
<span class="small">@(endpoint ?? health.ToString())</span>
</div>
@if (quality != null)
{
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small text-muted py-0">Tags good</td>
<td class="small text-end py-0">@quality.Good.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags bad</td>
<td class="small text-end py-0">@quality.Bad.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags uncertain</td>
<td class="small text-end py-0">@quality.Uncertain.ToString("N0")</td>
</tr>
</tbody>
</table>
}
</div>
}
}
</div>
</div>
@* Column 3: Instances + Store-and-Forward (collapsible) *@
<div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-queues")"
aria-expanded="false">
Instances &amp; Queues
</button>
<div class="collapse" id="@($"{detailsCollapseId}-queues")">
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Deployed</td>
<td class="text-end">@report.DeployedInstanceCount</td>
</tr>
<tr>
<td class="small">Enabled</td>
<td class="text-end text-success">@report.EnabledInstanceCount</td>
</tr>
<tr>
<td class="small">Disabled</td>
<td class="text-end">@report.DisabledInstanceCount</td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Store-and-Forward Buffers</h6>
@if (report.StoreAndForwardBufferDepths.Count == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@category</span>
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
</div>
}
}
</div>
</div>
@* Column 4: Error Counts + Parked Messages (collapsible) *@
<div class="col-md-6">
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
data-bs-toggle="collapse"
data-bs-target="@($"#{detailsCollapseId}-errors")"
aria-expanded="false">
Errors &amp; Parked Messages
</button>
<div class="collapse" id="@($"{detailsCollapseId}-errors")">
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Script Errors</td>
<td class="text-end">
<span class="@(report.ScriptErrorCount > 0 ? "text-danger fw-bold" : "")">@report.ScriptErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Alarm Eval Errors</td>
<td class="text-end">
<span class="@(report.AlarmEvaluationErrorCount > 0 ? "text-warning fw-bold" : "")">@report.AlarmEvaluationErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Dead Letters</td>
<td class="text-end">
<span class="@(report.DeadLetterCount > 0 ? "text-danger fw-bold" : "")">@report.DeadLetterCount</span>
</td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
@if (report.ParkedMessageCount == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
}
</div>
</div>
</div>
}
else
{
<span class="text-muted">No report data available.</span>
}
</div>
</div>
}
}
</div>
@code {
// Shape-coded status glyphs to pair with badge colour.
private const string OnlineGlyph = "●"; // ●
private const string OfflineGlyph = "○"; // ○
private const string PrimaryGlyph = "▲"; // ▲
private const string StandbyGlyph = "△"; // △
private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
private Dictionary<string, string> _siteNames = new();
private Timer? _refreshTimer;
private int _autoRefreshSeconds = 10;
// Notification Outbox headline KPIs, refreshed alongside the site states.
private NotificationKpiResponse _outboxKpi =
new(
CorrelationId: string.Empty,
Success: false,
ErrorMessage: null,
QueueDepth: 0,
StuckCount: 0,
ParkedCount: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null);
private bool _outboxKpiAvailable;
private string? _outboxKpiError;
// Audit Log (#23) M7 Bundle E — Audit KPI tiles. Volume + error rate come
// from a 1h aggregate over the central AuditLog table; backlog sums the
// per-site SiteAuditBacklog.PendingCount via the health aggregator.
private AuditLogKpiSnapshot? _auditKpi;
private bool _auditKpiAvailable;
private string? _auditKpiError;
// Site Call Audit (#22) Task 7 — Site Call KPI tiles. Point-in-time counts
// from the central SiteCalls table, fetched alongside the site states. The
// SiteCallKpiResponse message doubles as the snapshot the tile takes.
private SiteCallKpiResponse? _siteCallKpi;
private bool _siteCallKpiAvailable;
private string? _siteCallKpiError;
private static bool SiteHasActiveErrors(SiteHealthState state)
{
var report = state.LatestReport;
if (report == null) return false;
return report.ScriptErrorCount > 0
|| report.AlarmEvaluationErrorCount > 0
|| report.DeadLetterCount > 0;
}
protected override async Task OnInitializedAsync()
{
// Load site names for display
try
{
var sites = await SiteRepository.GetAllSitesAsync();
_siteNames = sites.ToDictionary(s => s.SiteIdentifier, s => s.Name);
}
catch
{
// Non-fatal — fall back to showing siteId only
}
await RefreshNow();
_refreshTimer = new Timer(_ =>
{
InvokeAsync(async () =>
{
await RefreshNow();
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
}
private async Task RefreshNow()
{
_siteStates = HealthAggregator.GetAllSiteStates();
await LoadOutboxKpis();
await LoadSiteCallKpis();
await LoadAuditKpis();
}
private async Task LoadOutboxKpis()
{
try
{
var response = await CommunicationService.GetNotificationKpisAsync(
new NotificationKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_outboxKpi = response;
_outboxKpiAvailable = true;
_outboxKpiError = null;
}
else
{
_outboxKpiAvailable = false;
_outboxKpiError = response.ErrorMessage ?? "KPI query failed.";
}
}
catch (Exception ex)
{
_outboxKpiAvailable = false;
_outboxKpiError = $"KPI query failed: {ex.Message}";
}
}
// Site Call KPI loader: wraps the service call so a transient fault degrades
// the three Site Call tiles to em dashes with an inline error rather than
// killing the dashboard. Mirrors LoadOutboxKpis's error handling shape — a
// response with Success == false (repository fault) and an Ask that threw
// (transport fault) both collapse to "unavailable".
private async Task LoadSiteCallKpis()
{
try
{
var response = await CommunicationService.GetSiteCallKpisAsync(
new SiteCallKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_siteCallKpi = response;
_siteCallKpiAvailable = true;
_siteCallKpiError = null;
}
else
{
_siteCallKpiAvailable = false;
_siteCallKpiError = response.ErrorMessage ?? "KPI query failed.";
}
}
catch (Exception ex)
{
_siteCallKpiAvailable = false;
_siteCallKpiError = $"KPI query failed: {ex.Message}";
}
}
// Tiles show the numeric KPI when available, or an em dash when the outbox
// KPI query failed — matching how the page renders other unavailable data.
private string OutboxTileValue(int value) =>
_outboxKpiAvailable ? value.ToString() : "—";
// Audit KPI loader: wraps the service call so a transient DB outage degrades
// the three tiles to em dashes with an inline error rather than killing the
// dashboard. Mirrors LoadOutboxKpis's error handling shape.
private async Task LoadAuditKpis()
{
try
{
_auditKpi = await AuditLogQueryService.GetKpiSnapshotAsync();
_auditKpiAvailable = true;
_auditKpiError = null;
}
catch (Exception ex)
{
_auditKpiAvailable = false;
_auditKpiError = $"KPI query failed: {ex.Message}";
}
}
private string GetSiteName(string siteId)
{
return _siteNames.GetValueOrDefault(siteId, siteId);
}
private static string GetConnectionHealthBadge(ConnectionHealth health) => health switch
{
ConnectionHealth.Connected => "bg-success",
ConnectionHealth.Connecting => "bg-warning text-dark",
ConnectionHealth.Disconnected => "bg-danger",
_ => "bg-secondary"
};
public void Dispose()
{
_refreshTimer?.Dispose();
}
}
@@ -0,0 +1,755 @@
@page "/monitoring/parked-messages"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.Communication
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject CommunicationService CommunicationService
@inject IJSRuntime JS
@inject IDialogService Dialog
@inject ILogger<ParkedMessages> Logger
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<div class="d-flex align-items-baseline flex-wrap mb-3">
<h4 class="mb-0 me-3">Parked Messages</h4>
@if (_messages != null && _messages.Count > 0)
{
<span class="text-muted small">
@_totalCount parked · @DistinctTargets target system@(DistinctTargets == 1 ? "" : "s")
@if (OldestMessage != null)
{
<span> · oldest @Relative(OldestMessage.LastAttemptTimestamp)</span>
}
@if (FilteredCount != _messages.Count)
{
<span class="ms-2">(showing @FilteredCount of @_messages.Count)</span>
}
</span>
}
</div>
<div class="card mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" for="pm-filter-site">Site</label>
<select id="pm-filter-site" class="form-select form-select-sm" style="min-width: 180px;"
value="@_selectedSiteId" @onchange="OnSiteChanged">
<option value="">Select site…</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="pm-filter-cat">Category</label>
<select id="pm-filter-cat" class="form-select form-select-sm" style="min-width: 150px;"
@bind="_categoryFilter">
<option value="">All</option>
<option value="ExternalSystem">External system</option>
<option value="Notification">Notification</option>
<option value="CachedDbWrite">DB write</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="pm-filter-target">Target</label>
<select id="pm-filter-target" class="form-select form-select-sm" style="min-width: 160px;"
@bind="_targetFilter">
<option value="">Any</option>
@foreach (var t in DistinctTargetsList)
{
<option value="@t">@t</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="pm-filter-origin">Origin</label>
<select id="pm-filter-origin" class="form-select form-select-sm" style="min-width: 160px;"
@bind="_originFilter">
<option value="">Any</option>
<option value="__none__">(none)</option>
@foreach (var o in DistinctOriginsList)
{
<option value="@o">@o</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="pm-filter-age">Age</label>
<select id="pm-filter-age" class="form-select form-select-sm" style="min-width: 130px;"
@bind="_ageFilter">
<option value="All">All</option>
<option value="LastHour">Last hour</option>
<option value="LastDay">Last 24h</option>
<option value="LastWeek">Last 7d</option>
</select>
</div>
<div class="col">
<label class="form-label small mb-1" for="pm-filter-search">Search</label>
<input id="pm-filter-search" type="search" class="form-control form-control-sm"
placeholder="ID, target, method, error…"
@bind="_searchFilter" @bind:event="oninput" />
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
disabled="@(!HasActiveFilters)">Clear</button>
</div>
<div class="col-auto">
<button class="btn btn-primary btn-sm" @onclick="Search"
disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Query
</button>
</div>
</div>
</div>
</div>
@if (_selectedIds.Count > 0)
{
<div class="alert alert-secondary py-2 d-flex align-items-center mb-3">
<strong class="me-3">@_selectedIds.Count selected</strong>
<button class="btn btn-outline-success btn-sm me-2"
@onclick="BulkRetry" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry selected
</button>
<button class="btn btn-outline-danger btn-sm me-2"
@onclick="BulkDiscard" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard selected
</button>
<button type="button" class="btn-close ms-auto"
aria-label="Clear selection" @onclick="ClearSelection"></button>
</div>
}
@if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
@if (_messages == null)
{
@if (!string.IsNullOrEmpty(_selectedSiteId) && _searching)
{
<div class="text-muted small">Loading…</div>
}
}
else if (_messages.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-5">
<div class="fs-5 mb-1">No parked messages</div>
<div class="small">Nothing has failed enough to give up on at this site.</div>
</div>
</div>
}
else
{
var filtered = FilteredMessages;
<div class="table-responsive">
<table class="table table-sm table-hover mb-2 align-middle parked-table">
<thead class="table-light">
<tr>
<th style="width: 36px;">
<input class="form-check-input" type="checkbox"
checked="@AllFilteredSelected"
@onchange="ToggleSelectAll"
aria-label="Select all" />
</th>
<th>Target / Method</th>
<th>Origin</th>
<th>Error</th>
<th style="width: 110px;">Attempts</th>
<th style="width: 160px;">Last attempt</th>
</tr>
</thead>
<tbody>
@if (filtered.Count == 0)
{
<tr><td colspan="6" class="text-muted text-center py-3">No messages match the current filters.</td></tr>
}
@foreach (var msg in filtered)
{
var isSelected = _selectedIds.Contains(msg.MessageId);
<tr @key="msg.MessageId"
class="parked-row @SeverityClass(msg) @(isSelected ? "table-active" : "")"
@onclick="() => OpenDrawer(msg)"
style="cursor: pointer;">
<td @onclick:stopPropagation="true">
<input class="form-check-input" type="checkbox"
checked="@isSelected"
@onchange="e => ToggleSelect(msg.MessageId, (bool)e.Value!)"
aria-label="@($"Select {msg.MessageId[..Math.Min(8, msg.MessageId.Length)]}")" />
</td>
<td>
<div class="fw-semibold">@msg.TargetSystem</div>
<div class="small text-muted">@msg.MethodName</div>
</td>
<td>
@if (!string.IsNullOrEmpty(msg.OriginInstance))
{
<code class="small">@msg.OriginInstance</code>
}
else
{
<span class="text-muted small">—</span>
}
</td>
<td>
<div class="text-danger small parked-error-clamp">@msg.ErrorMessage</div>
</td>
<td>
<div class="small font-monospace">
@msg.AttemptCount<span class="text-muted">/@msg.MaxAttempts</span>
</div>
<div class="progress mt-1" style="height: 3px;">
<div class="progress-bar @AttemptBarClass(msg)"
role="progressbar"
style="width: @AttemptPercent(msg)%;"
aria-valuenow="@msg.AttemptCount"
aria-valuemin="0"
aria-valuemax="@Math.Max(1, msg.MaxAttempts)"></div>
</div>
</td>
<td>
<div class="small" title="@AbsoluteUtc(msg.LastAttemptTimestamp)">
@Relative(msg.LastAttemptTimestamp)
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (_totalCount > _pageSize)
{
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">
Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total
</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="PrevPage" disabled="@(_pageNumber <= 1)">Previous</button>
<button class="btn btn-outline-secondary btn-sm"
@onclick="NextPage" disabled="@(_messages.Count < _pageSize)">Next</button>
</div>
</div>
}
}
</div>
@if (_drawerMessage != null)
{
<div class="offcanvas-backdrop fade show" @onclick="CloseDrawer"></div>
<div class="offcanvas offcanvas-end show parked-drawer" tabindex="-1" style="visibility: visible;">
<div class="offcanvas-header border-bottom">
<div>
<div class="text-muted small text-uppercase">Parked message</div>
<h5 class="offcanvas-title mb-0">@_drawerMessage.TargetSystem</h5>
<div class="small text-muted">@_drawerMessage.MethodName</div>
</div>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseDrawer"></button>
</div>
<div class="offcanvas-body small">
<dl class="row mb-3">
<dt class="col-4 text-muted fw-normal">Message ID</dt>
<dd class="col-8 d-flex align-items-center gap-2">
<code class="text-truncate" style="min-width: 0;">@_drawerMessage.MessageId</code>
<button class="btn btn-link btn-sm p-0" title="Copy message ID"
@onclick="() => CopyAsync(_drawerMessage.MessageId)">📋</button>
</dd>
<dt class="col-4 text-muted fw-normal">Category</dt>
<dd class="col-8">@CategoryLabel(_drawerMessage.Category)</dd>
<dt class="col-4 text-muted fw-normal">Origin instance</dt>
<dd class="col-8">
@if (!string.IsNullOrEmpty(_drawerMessage.OriginInstance))
{
<code>@_drawerMessage.OriginInstance</code>
}
else
{
<span class="text-muted">—</span>
}
</dd>
<dt class="col-4 text-muted fw-normal">Attempts</dt>
<dd class="col-8 font-monospace">@_drawerMessage.AttemptCount / @_drawerMessage.MaxAttempts</dd>
<dt class="col-4 text-muted fw-normal">Originally enqueued</dt>
<dd class="col-8">
@Relative(_drawerMessage.OriginalTimestamp)
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.OriginalTimestamp)</span>
</dd>
<dt class="col-4 text-muted fw-normal">Last attempt</dt>
<dd class="col-8">
@Relative(_drawerMessage.LastAttemptTimestamp)
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.LastAttemptTimestamp)</span>
</dd>
</dl>
<div class="text-muted text-uppercase small fw-semibold mb-1">Error</div>
<pre class="bg-light border rounded p-2 small mb-0 parked-error-pre">@_drawerMessage.ErrorMessage</pre>
</div>
<div class="border-top p-3 d-flex gap-2">
<button class="btn btn-outline-success btn-sm flex-grow-1"
@onclick="RetryFromDrawer" disabled="@_actionInProgress">
@if (_actionInProgress && _activeAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry
</button>
<button class="btn btn-outline-danger btn-sm flex-grow-1"
@onclick="DiscardFromDrawer" disabled="@_actionInProgress">
@if (_actionInProgress && _activeAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard
</button>
</div>
</div>
}
<style>
.parked-row { border-left: 3px solid transparent; }
.parked-row.sev-danger { border-left-color: var(--bs-danger); }
.parked-row.sev-warning { border-left-color: var(--bs-warning); }
.parked-row.sev-secondary { border-left-color: var(--bs-secondary-bg-subtle); }
.parked-error-clamp {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
max-width: 520px;
}
.parked-drawer { width: min(560px, 95vw); }
.parked-error-pre { white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; }
.parked-table tbody tr { transition: background-color 0.1s ease; }
</style>
@code {
private List<Site> _sites = new();
private string _selectedSiteId = string.Empty;
private List<ParkedMessageEntry>? _messages;
private int _totalCount;
private int _pageNumber = 1;
private int _pageSize = 50;
private bool _searching;
private string? _errorMessage;
// Filters
private string _categoryFilter = string.Empty;
private string _targetFilter = string.Empty;
private string _originFilter = string.Empty;
private string _ageFilter = "All";
private string _searchFilter = string.Empty;
// Selection
private readonly HashSet<string> _selectedIds = new();
private bool _bulkInProgress;
private string? _bulkAction;
// Per-row action state
private bool _actionInProgress;
private string? _activeAction;
// Drawer
private ParkedMessageEntry? _drawerMessage;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
// Site scoping (CentralUI-002): a scoped Deployment user may only inspect
// and act on parked messages for the sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
}
// True only when the currently selected SiteIdentifier is one this user is
// permitted on. _sites is already filtered, so membership IS the scope check.
private bool SelectedSiteIsPermitted =>
!string.IsNullOrEmpty(_selectedSiteId)
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
private async Task OnSiteChanged(ChangeEventArgs e)
{
_selectedSiteId = e.Value?.ToString() ?? string.Empty;
if (!string.IsNullOrEmpty(_selectedSiteId))
{
await Search();
}
else
{
_messages = null;
_selectedIds.Clear();
_drawerMessage = null;
}
}
private async Task Search()
{
_pageNumber = 1;
_selectedIds.Clear();
_drawerMessage = null;
await FetchPage();
}
private async Task PrevPage() { _pageNumber--; _selectedIds.Clear(); await FetchPage(); }
private async Task NextPage() { _pageNumber++; _selectedIds.Clear(); await FetchPage(); }
private async Task FetchPage()
{
_searching = true;
_errorMessage = null;
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
// filtered, but the selection must not be trusted on its own.
if (!SelectedSiteIsPermitted)
{
_errorMessage = "You are not permitted to view parked messages for that site.";
_messages = null;
_searching = false;
return;
}
try
{
var request = new ParkedMessageQueryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
SiteId: _selectedSiteId,
PageNumber: _pageNumber,
PageSize: _pageSize,
Timestamp: DateTimeOffset.UtcNow);
var response = await CommunicationService.QueryParkedMessagesAsync(_selectedSiteId, request);
if (response.Success)
{
_messages = response.Messages.ToList();
_totalCount = response.TotalCount;
}
else
{
_errorMessage = response.ErrorMessage ?? "Query failed.";
}
}
catch (Exception ex)
{
_errorMessage = $"Query failed: {ex.Message}";
}
_searching = false;
}
private void ClearFilters()
{
_categoryFilter = string.Empty;
_targetFilter = string.Empty;
_originFilter = string.Empty;
_ageFilter = "All";
_searchFilter = string.Empty;
}
private bool HasActiveFilters =>
!string.IsNullOrEmpty(_categoryFilter) ||
!string.IsNullOrEmpty(_targetFilter) ||
!string.IsNullOrEmpty(_originFilter) ||
_ageFilter != "All" ||
!string.IsNullOrEmpty(_searchFilter);
private List<ParkedMessageEntry> FilteredMessages
{
get
{
if (_messages == null) return new();
IEnumerable<ParkedMessageEntry> q = _messages;
if (!string.IsNullOrEmpty(_categoryFilter) &&
Enum.TryParse<StoreAndForwardCategory>(_categoryFilter, out var cat))
q = q.Where(m => m.Category == cat);
if (!string.IsNullOrEmpty(_targetFilter))
q = q.Where(m => m.TargetSystem == _targetFilter);
if (_originFilter == "__none__")
q = q.Where(m => string.IsNullOrEmpty(m.OriginInstance));
else if (!string.IsNullOrEmpty(_originFilter))
q = q.Where(m => m.OriginInstance == _originFilter);
if (_ageFilter != "All")
{
var cutoff = _ageFilter switch
{
"LastHour" => DateTimeOffset.UtcNow.AddHours(-1),
"LastDay" => DateTimeOffset.UtcNow.AddDays(-1),
"LastWeek" => DateTimeOffset.UtcNow.AddDays(-7),
_ => DateTimeOffset.MinValue
};
q = q.Where(m => m.LastAttemptTimestamp >= cutoff);
}
if (!string.IsNullOrEmpty(_searchFilter))
{
var s = _searchFilter.Trim();
q = q.Where(m =>
m.MessageId.Contains(s, StringComparison.OrdinalIgnoreCase) ||
m.TargetSystem.Contains(s, StringComparison.OrdinalIgnoreCase) ||
m.MethodName.Contains(s, StringComparison.OrdinalIgnoreCase) ||
m.ErrorMessage.Contains(s, StringComparison.OrdinalIgnoreCase) ||
(m.OriginInstance ?? string.Empty).Contains(s, StringComparison.OrdinalIgnoreCase));
}
return q.ToList();
}
}
private int FilteredCount => FilteredMessages.Count;
private int DistinctTargets => _messages?.Select(m => m.TargetSystem).Distinct().Count() ?? 0;
private IEnumerable<string> DistinctTargetsList =>
_messages?.Select(m => m.TargetSystem).Distinct().OrderBy(s => s).ToList()
?? (IEnumerable<string>)Array.Empty<string>();
private IEnumerable<string> DistinctOriginsList =>
_messages?.Where(m => !string.IsNullOrEmpty(m.OriginInstance))
.Select(m => m.OriginInstance!).Distinct().OrderBy(s => s).ToList()
?? (IEnumerable<string>)Array.Empty<string>();
private ParkedMessageEntry? OldestMessage =>
_messages?.OrderBy(m => m.LastAttemptTimestamp).FirstOrDefault();
// ── Selection ──
private bool AllFilteredSelected
{
get
{
var filtered = FilteredMessages;
return filtered.Count > 0 && filtered.All(m => _selectedIds.Contains(m.MessageId));
}
}
private void ToggleSelect(string id, bool isChecked)
{
if (isChecked) _selectedIds.Add(id);
else _selectedIds.Remove(id);
}
private void ToggleSelectAll(ChangeEventArgs e)
{
var on = (bool)e.Value!;
var filtered = FilteredMessages;
if (on)
{
foreach (var m in filtered) _selectedIds.Add(m.MessageId);
}
else
{
foreach (var m in filtered) _selectedIds.Remove(m.MessageId);
}
}
private void ClearSelection() => _selectedIds.Clear();
// ── Drawer ──
private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessage = msg;
private void CloseDrawer() => _drawerMessage = null;
private async Task RetryFromDrawer()
{
if (_drawerMessage == null) return;
var msg = _drawerMessage;
await RetrySingle(msg);
CloseDrawer();
}
private async Task DiscardFromDrawer()
{
if (_drawerMessage == null) return;
var msg = _drawerMessage;
var ok = await DiscardSingle(msg);
if (ok) CloseDrawer();
}
// ── Bulk ──
private async Task BulkRetry()
{
var ids = _selectedIds.ToList();
if (ids.Count == 0) return;
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
var confirmed = await Dialog.ConfirmAsync(
"Retry parked messages",
$"Move {ids.Count} message{(ids.Count == 1 ? "" : "s")} back to the pending queue?");
if (!confirmed) return;
_bulkInProgress = true;
_bulkAction = "Retry";
int success = 0, failed = 0;
foreach (var id in ids)
{
try
{
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
if (resp.Success) success++; else failed++;
}
catch { failed++; }
}
_toast.ShowSuccess($"{success} queued for retry" + (failed > 0 ? $", {failed} failed" : "."));
_selectedIds.Clear();
_bulkInProgress = false;
_bulkAction = null;
await FetchPage();
}
private async Task BulkDiscard()
{
var ids = _selectedIds.ToList();
if (ids.Count == 0) return;
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
var confirmed = await Dialog.ConfirmAsync(
"Discard parked messages",
$"Permanently discard {ids.Count} message{(ids.Count == 1 ? "" : "s")}? This cannot be undone.",
danger: true);
if (!confirmed) return;
_bulkInProgress = true;
_bulkAction = "Discard";
int success = 0, failed = 0;
foreach (var id in ids)
{
try
{
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
if (resp.Success) success++; else failed++;
}
catch { failed++; }
}
_toast.ShowSuccess($"{success} discarded" + (failed > 0 ? $", {failed} failed" : "."));
_selectedIds.Clear();
_bulkInProgress = false;
_bulkAction = null;
await FetchPage();
}
// ── Single actions ──
private async Task RetrySingle(ParkedMessageEntry msg)
{
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
_actionInProgress = true;
_activeAction = "Retry";
try
{
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
if (resp.Success)
{
_toast.ShowSuccess($"Message {ShortId(msg.MessageId)} queued for retry.");
await FetchPage();
}
else _toast.ShowError(resp.ErrorMessage ?? "Retry failed.");
}
catch (Exception ex) { _toast.ShowError($"Retry failed: {ex.Message}"); }
_actionInProgress = false;
_activeAction = null;
}
private async Task<bool> DiscardSingle(ParkedMessageEntry msg)
{
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return false; }
var confirmed = await Dialog.ConfirmAsync(
"Discard parked message",
$"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
danger: true);
if (!confirmed) return false;
_actionInProgress = true;
_activeAction = "Discard";
bool ok = false;
try
{
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
if (resp.Success)
{
_toast.ShowSuccess($"Message {ShortId(msg.MessageId)} discarded.");
ok = true;
await FetchPage();
}
else _toast.ShowError(resp.ErrorMessage ?? "Discard failed.");
}
catch (Exception ex) { _toast.ShowError($"Discard failed: {ex.Message}"); }
_actionInProgress = false;
_activeAction = null;
return ok;
}
private async Task CopyAsync(string text)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
_toast.ShowSuccess("Copied to clipboard.");
}
catch (JSDisconnectedException)
{
// Circuit gone — the page is being torn down; nothing to surface.
// CentralUI-023: distinguished from a genuine interop failure.
}
catch (JSException ex)
{
// A real clipboard failure (e.g. permission denied) — surface it to
// the user and log it so it is not invisible in production.
Logger.LogWarning(ex, "Clipboard copy failed.");
_toast.ShowError("Copy failed.");
}
}
// ── Helpers ──
private static string ShortId(string id) => id[..Math.Min(12, id.Length)];
private static string Relative(DateTimeOffset t)
{
var diff = DateTimeOffset.UtcNow - t;
if (diff.TotalSeconds < 0) return "just now";
if (diff.TotalSeconds < 60) return "just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
if (diff.TotalDays < 30) return $"{(int)diff.TotalDays}d ago";
return t.UtcDateTime.ToString("yyyy-MM-dd");
}
private static string AbsoluteUtc(DateTimeOffset t) =>
$"{t.UtcDateTime:yyyy-MM-dd HH:mm:ss} UTC";
private static string SeverityClass(ParkedMessageEntry msg)
{
var exhausted = msg.MaxAttempts > 0 && msg.AttemptCount >= msg.MaxAttempts;
if (!exhausted) return "sev-secondary";
var age = DateTimeOffset.UtcNow - msg.LastAttemptTimestamp;
return age < TimeSpan.FromHours(1) ? "sev-danger" : "sev-warning";
}
private static int AttemptPercent(ParkedMessageEntry msg)
{
if (msg.MaxAttempts <= 0) return 100;
var pct = (int)Math.Round(msg.AttemptCount * 100.0 / msg.MaxAttempts);
return Math.Clamp(pct, 0, 100);
}
private static string AttemptBarClass(ParkedMessageEntry msg) =>
msg.AttemptCount >= msg.MaxAttempts ? "bg-danger" : "bg-warning";
private static string CategoryLabel(StoreAndForwardCategory c) => c switch
{
StoreAndForwardCategory.ExternalSystem => "External system",
StoreAndForwardCategory.Notification => "Notification",
StoreAndForwardCategory.CachedDbWrite => "Cached DB write",
_ => c.ToString()
};
}
@@ -0,0 +1,209 @@
@page "/notifications/kpis"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications
@using ZB.MOM.WW.ScadaBridge.Communication
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject ILogger<NotificationKpis> Logger
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Notification KPIs</h4>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Refresh
</button>
</div>
@* ── Global KPI tiles ── *@
@if (_kpiError != null)
{
<div class="alert alert-warning py-2">KPIs unavailable: @_kpiError</div>
}
else
{
<div class="row g-3 mb-4">
<div class="col-lg col-md-4 col-6">
<div class="card h-100">
<div class="card-body text-center py-3">
<h3 class="mb-0">@_kpi.QueueDepth</h3>
<small class="text-muted">Queue Depth</small>
</div>
</div>
</div>
<div class="col-lg col-md-4 col-6">
<div class="card h-100 @(_kpi.StuckCount > 0 ? "border-warning" : "")">
<div class="card-body text-center py-3">
<h3 class="mb-0 @(_kpi.StuckCount > 0 ? "text-warning" : "")">@_kpi.StuckCount</h3>
<small class="text-muted">Stuck</small>
</div>
</div>
</div>
<div class="col-lg col-md-4 col-6">
<div class="card h-100 @(_kpi.ParkedCount > 0 ? "border-danger" : "")">
<div class="card-body text-center py-3">
<h3 class="mb-0 @(_kpi.ParkedCount > 0 ? "text-danger" : "")">@_kpi.ParkedCount</h3>
<small class="text-muted">Parked</small>
</div>
</div>
</div>
<div class="col-lg col-md-4 col-6">
<div class="card h-100">
<div class="card-body text-center py-3">
<h3 class="mb-0 text-success">@_kpi.DeliveredLastInterval</h3>
<small class="text-muted">Delivered (last interval)</small>
</div>
</div>
</div>
<div class="col-lg col-md-4 col-6">
<div class="card h-100">
<div class="card-body text-center py-3">
<h3 class="mb-0">@FormatAge(_kpi.OldestPendingAge)</h3>
<small class="text-muted">Oldest Pending Age</small>
</div>
</div>
</div>
</div>
}
@* ── Per-site breakdown ── *@
<h5 class="mb-2">Per-site breakdown</h5>
@if (_perSiteError != null)
{
<div class="alert alert-warning py-2">Per-site KPIs unavailable: @_perSiteError</div>
}
else if (_perSite.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-4">
<div class="small">No per-site activity.</div>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Site</th>
<th class="text-end">Queue Depth</th>
<th class="text-end">Stuck</th>
<th class="text-end">Parked</th>
<th class="text-end">Delivered (last interval)</th>
<th class="text-end">Oldest Pending Age</th>
</tr>
</thead>
<tbody>
@foreach (var s in _perSite)
{
<tr @key="s.SourceSiteId" class="@(s.StuckCount > 0 ? "table-warning" : "")">
<td>@SiteName(s.SourceSiteId)</td>
<td class="text-end font-monospace">@s.QueueDepth</td>
<td class="text-end font-monospace @(s.StuckCount > 0 ? "text-warning" : "")">@s.StuckCount</td>
<td class="text-end font-monospace @(s.ParkedCount > 0 ? "text-danger" : "")">@s.ParkedCount</td>
<td class="text-end font-monospace text-success">@s.DeliveredLastInterval</td>
<td class="text-end font-monospace">@FormatAge(s.OldestPendingAge)</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private List<Site> _sites = new();
private NotificationKpiResponse _kpi = new(string.Empty, true, null, 0, 0, 0, 0, null);
private string? _kpiError;
private IReadOnlyList<SiteNotificationKpiSnapshot> _perSite = Array.Empty<SiteNotificationKpiSnapshot>();
private string? _perSiteError;
private bool _loading;
protected override async Task OnInitializedAsync()
{
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
}
catch (Exception ex)
{
// Non-fatal — the per-site table falls back to raw site identifiers.
Logger.LogWarning(ex, "Failed to load sites for the KPI per-site breakdown.");
}
await RefreshAll();
}
private async Task RefreshAll()
{
_loading = true;
// Race-free despite both tasks mutating component fields: Blazor Server runs
// every continuation on the circuit's single-threaded synchronization context.
await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis());
_loading = false;
}
private async Task LoadGlobalKpis()
{
try
{
var response = await CommunicationService.GetNotificationKpisAsync(
new NotificationKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_kpi = response;
_kpiError = null;
}
else
{
_kpiError = response.ErrorMessage ?? "KPI query failed.";
}
}
catch (Exception ex)
{
_kpiError = $"KPI query failed: {ex.Message}";
}
}
private async Task LoadPerSiteKpis()
{
try
{
var response = await CommunicationService.GetPerSiteNotificationKpisAsync(
new PerSiteNotificationKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_perSite = response.Sites;
_perSiteError = null;
}
else
{
_perSiteError = response.ErrorMessage ?? "Per-site KPI query failed.";
}
}
catch (Exception ex)
{
_perSiteError = $"Per-site KPI query failed: {ex.Message}";
}
}
private string SiteName(string siteId) =>
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;
private static string FormatAge(TimeSpan? age)
{
if (age == null) return "—";
var t = age.Value;
if (t.TotalSeconds < 60) return $"{(int)t.TotalSeconds}s";
if (t.TotalMinutes < 60) return $"{(int)t.TotalMinutes}m";
if (t.TotalHours < 24) return $"{(int)t.TotalHours}h";
return $"{(int)t.TotalDays}d";
}
}
@@ -0,0 +1,190 @@
@page "/notifications/lists/create"
@page "/notifications/lists/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit Notification List" : "Add Notification List")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@if (Id.HasValue)
{
<h5 class="mt-4 mb-3">Recipients</h5>
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_recipientName" />
</div>
<div class="mb-2">
<label class="form-label">Email</label>
<input type="email" class="form-control" @bind="_recipientEmail" />
</div>
@if (_recipientFormError != null)
{
<div class="text-danger small mt-2">@_recipientFormError</div>
}
<div class="mt-3">
<button class="btn btn-success" @onclick="SaveRecipient">Add</button>
</div>
</div>
</div>
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Email</th>
<th style="width:80px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_recipients.Count == 0)
{
<tr><td colspan="3" class="text-muted small">No recipients.</td></tr>
}
else
{
@foreach (var r in _recipients)
{
<tr>
<td>@r.Name</td>
<td>@r.EmailAddress</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
</td>
</tr>
}
}
</tbody>
</table>
}
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "";
private string? _formError;
private NotificationList? _existing;
// Recipients
private List<NotificationRecipient> _recipients = new();
private string _recipientName = "", _recipientEmail = "";
private string? _recipientFormError;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await NotificationRepository.GetNotificationListByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
}
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name))
{
_formError = "Name required.";
return;
}
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
await NotificationRepository.UpdateNotificationListAsync(_existing);
}
else
{
var nl = new NotificationList(_name.Trim());
await NotificationRepository.AddNotificationListAsync(nl);
}
await NotificationRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/notifications/lists");
}
catch (Exception ex) { _formError = ex.Message; }
}
private async Task SaveRecipient()
{
_recipientFormError = null;
if (string.IsNullOrWhiteSpace(_recipientName) || string.IsNullOrWhiteSpace(_recipientEmail))
{
_recipientFormError = "Name and email required.";
return;
}
try
{
var r = new NotificationRecipient(_recipientName.Trim(), _recipientEmail.Trim())
{
NotificationListId = Id!.Value
};
await NotificationRepository.AddRecipientAsync(r);
await NotificationRepository.SaveChangesAsync();
_recipientName = _recipientEmail = string.Empty;
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
}
private async Task DeleteRecipient(NotificationRecipient r)
{
try
{
await NotificationRepository.DeleteRecipientAsync(r.Id);
await NotificationRepository.SaveChangesAsync();
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id!.Value)).ToList();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/notifications/lists");
}
@@ -0,0 +1,137 @@
@page "/notifications/lists"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Notification Lists</h4>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/notifications/lists/create")'>
Add Notification List
</button>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else if (_lists.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-5">
<div class="fs-5 mb-2">No notification lists</div>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/notifications/lists/create")'>
Add your first notification list
</button>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Recipients</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var list in _lists)
{
var recipients = _recipients.GetValueOrDefault(list.Id)
?? (IReadOnlyList<NotificationRecipient>)Array.Empty<NotificationRecipient>();
<tr @key="list.Id">
<td>@list.Name</td>
<td>
@if (recipients.Count == 0)
{
<span class="text-muted small fst-italic">No recipients</span>
}
else
{
@foreach (var r in recipients)
{
<span class="badge bg-light text-dark me-1 mb-1">@r.Name &lt;@r.EmailAddress&gt;</span>
}
}
</td>
<td class="text-end">
<button class="btn btn-outline-primary btn-sm me-1"
@onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
Edit
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DeleteList(list)">
Delete
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private bool _loading = true;
private string? _errorMessage;
private List<NotificationList> _lists = new();
private readonly Dictionary<int, IReadOnlyList<NotificationRecipient>> _recipients = new();
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
_loading = true;
_errorMessage = null;
try
{
_lists = (await NotificationRepository.GetAllNotificationListsAsync()).ToList();
_recipients.Clear();
foreach (var list in _lists)
{
_recipients[list.Id] = await NotificationRepository.GetRecipientsByListIdAsync(list.Id);
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load notification lists: {ex.Message}";
}
_loading = false;
}
private async Task DeleteList(NotificationList list)
{
if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true))
{
return;
}
try
{
await NotificationRepository.DeleteNotificationListAsync(list.Id);
await NotificationRepository.SaveChangesAsync();
_toast.ShowSuccess("Deleted.");
await LoadAsync();
}
catch (Exception ex)
{
_toast.ShowError($"Failed to delete notification list: {ex.Message}");
}
}
}
@@ -0,0 +1,741 @@
@page "/notifications/report"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification
@using ZB.MOM.WW.ScadaBridge.Communication
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject SiteScopeService SiteScope
@inject IDialogService Dialog
@inject ILogger<NotificationReport> Logger
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Notification Report</h4>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Refresh
</button>
</div>
@* ── Filters ── *@
<div class="card mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" for="no-status">Status</label>
<select id="no-status" class="form-select form-select-sm" style="min-width: 130px;"
@bind="_statusFilter">
<option value="">All</option>
<option value="Pending">Pending</option>
<option value="Retrying">Retrying</option>
<option value="Delivered">Delivered</option>
<option value="Parked">Parked</option>
<option value="Discarded">Discarded</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="no-type">Type</label>
<select id="no-type" class="form-select form-select-sm" style="min-width: 120px;"
@bind="_typeFilter">
<option value="">All</option>
<option value="Email">Email</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="no-site">Source site</label>
<select id="no-site" class="form-select form-select-sm" style="min-width: 150px;"
@bind="_siteFilter">
<option value="">Any</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="no-list">List name</label>
<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"
@bind="_fromFilter" />
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="no-to">To</label>
<input id="no-to" type="datetime-local" class="form-control form-control-sm"
@bind="_toFilter" />
</div>
<div class="col">
<label class="form-label small mb-1" for="no-search">Subject keyword</label>
<input id="no-search" type="search" class="form-control form-control-sm"
placeholder="Search subject…" @bind="_subjectFilter" />
</div>
<div class="col-auto">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="no-stuck-only"
@bind="_stuckOnly" />
<label class="form-check-label small" for="no-stuck-only">Stuck only</label>
</div>
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
disabled="@(!HasActiveFilters)">Clear</button>
</div>
<div class="col-auto">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_loading">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Query
</button>
</div>
</div>
</div>
</div>
@if (_listError != null)
{
<div class="alert alert-danger">@_listError</div>
}
@* ── Notification list ── *@
@if (_notifications == null)
{
@if (_loading)
{
<div class="text-muted small">Loading…</div>
}
}
else if (_notifications.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-5">
<div class="fs-5 mb-1">No notifications</div>
<div class="small">No notifications match the current filters.</div>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Type</th>
<th>List</th>
<th>Subject</th>
<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>
</tr>
</thead>
<tbody>
@foreach (var n in _notifications)
{
<tr @key="n.NotificationId" class="@(n.IsStuck ? "table-warning" : "")"
style="cursor: pointer;" @ondblclick="() => ShowDetail(n)"
title="Double-click for full detail">
<td><code class="small" title="@n.NotificationId">@ShortId(n.NotificationId)</code></td>
<td>@n.Type</td>
<td>@n.ListName</td>
<td>
@n.Subject
@if (!string.IsNullOrEmpty(n.LastError))
{
<div class="small text-danger text-truncate" style="max-width: 320px;"
title="@n.LastError">@n.LastError</div>
}
</td>
<td>
<span class="badge @StatusBadgeClass(n.Status)">@n.Status</span>
@if (n.IsStuck)
{
<span class="badge bg-warning text-dark ms-1">Stuck</span>
}
</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">
@* Bundle D (#23 M7-T10) drill-in: NotificationId is the audit
CorrelationId, so the link deep-links into the central Audit
Log pre-filtered to this notification's lifecycle events. *@
<a class="btn btn-outline-secondary btn-sm me-1"
href="/audit/log?correlationId=@n.NotificationId"
data-test="audit-link-@n.NotificationId">
View audit history
</a>
@if (n.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm me-1"
@onclick="() => RetryNotification(n)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardNotification(n)" disabled="@_actionInProgress">
Discard
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
@if (_totalCount > _pageSize)
{
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">
Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total
</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="PrevPage" disabled="@(_pageNumber <= 1 || _loading)">Previous</button>
<button class="btn btn-outline-secondary btn-sm"
@onclick="NextPage" disabled="@(_notifications.Count < _pageSize || _loading)">Next</button>
</div>
</div>
}
}
</div>
@* ── Row detail modal ── *@
@if (_detailNotification != null)
{
var d = _detailNotification;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Notification Detail — @ShortId(d.NotificationId)</h6>
<button type="button" class="btn-close" aria-label="Close"
@onclick="CloseDetail"></button>
</div>
<div class="modal-body">
<dl class="row mb-0">
<dt class="col-sm-3">Notification ID</dt>
<dd class="col-sm-9"><code>@d.NotificationId</code></dd>
<dt class="col-sm-3">Type</dt>
<dd class="col-sm-9">@d.Type</dd>
<dt class="col-sm-3">List</dt>
<dd class="col-sm-9">@d.ListName</dd>
<dt class="col-sm-3">Subject</dt>
<dd class="col-sm-9">@d.Subject</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge @StatusBadgeClass(d.Status)">@d.Status</span>
@if (d.IsStuck)
{
<span class="badge bg-warning text-dark ms-1">Stuck</span>
}
</dd>
<dt class="col-sm-3">Stuck</dt>
<dd class="col-sm-9">@(d.IsStuck ? "Yes" : "No")</dd>
<dt class="col-sm-3">Retry count</dt>
<dd class="col-sm-9 font-monospace">@d.RetryCount</dd>
<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>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9"><TimestampDisplay Value="@d.CreatedAt" Format="yyyy-MM-dd HH:mm:ss" /></dd>
<dt class="col-sm-3">Delivered</dt>
<dd class="col-sm-9"><TimestampDisplay Value="@d.DeliveredAt" Format="yyyy-MM-dd HH:mm:ss" NullText="—" /></dd>
@if (!string.IsNullOrEmpty(d.LastError))
{
<dt class="col-sm-3">Last error</dt>
<dd class="col-sm-9 text-danger">@d.LastError</dd>
}
</dl>
@* ── Recipients ── *@
<hr />
<h6 class="mb-2">Recipients</h6>
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
var recipients = ParseRecipients(_detail.ResolvedTargets);
if (recipients.Count > 0)
{
<ul class="mb-0">
@foreach (var recipient in recipients)
{
<li>@recipient</li>
}
</ul>
}
else
{
<div class="text-muted small">
Not yet resolved — recipients are resolved from list
"@d.ListName" at delivery time.
</div>
}
}
@* ── Body ── *@
<hr />
<h6 class="mb-2">Message body</h6>
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
@* Email bodies are plain text (design: BCC delivery, plain text).
Rendered as preformatted text — never as a MarkupString, which
would be an XSS vector. *@
<pre class="border rounded bg-light p-2 mb-0"
style="max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word;">@_detail.Body</pre>
}
</div>
<div class="modal-footer">
@if (d.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
}
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
</div>
</div>
</div>
</div>
}
@code {
private const int _pageSize = 50;
private ToastNotification _toast = default!;
private List<Site> _sites = new();
// CentralUI-028: full site list (kept unfiltered) so a permitted-site check
// resolves correctly for a SourceSiteId whose Site was filtered out of the
// dropdown. Set once in OnInitializedAsync alongside _sites.
private List<Site> _allSites = new();
private bool _siteScopeSystemWide;
private HashSet<int> _permittedSiteIds = new();
// List
private List<NotificationSummary>? _notifications;
private int _totalCount;
private int _pageNumber = 1;
private bool _loading;
private string? _listError;
private bool _actionInProgress;
// Row detail modal
private NotificationSummary? _detailNotification;
private NotificationDetail? _detail;
private bool _detailLoading;
private string? _detailError;
// Filters
private string _statusFilter = string.Empty;
private string _typeFilter = string.Empty;
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;
protected override async Task OnInitializedAsync()
{
try
{
_allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
// CentralUI-028: restrict the site dropdown to the user's permitted set
// so a site-scoped Deployment user cannot select a site they have no
// grant for. System-wide users see the full list back unchanged.
_sites = await SiteScope.FilterSitesAsync(_allSites);
_siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
_permittedSiteIds = new HashSet<int>(await SiteScope.PermittedSiteIdsAsync());
}
catch (Exception ex)
{
// Non-fatal — source-site filter just falls back to the raw site IDs.
Logger.LogWarning(ex, "Failed to load sites for the report source-site filter.");
}
await RefreshAll();
}
private async Task RefreshAll()
{
await FetchPage();
}
private async Task Search()
{
_pageNumber = 1;
await FetchPage();
}
private async Task PrevPage() { _pageNumber--; await FetchPage(); }
private async Task NextPage() { _pageNumber++; await FetchPage(); }
private async Task FetchPage()
{
_loading = true;
_listError = null;
try
{
var request = new NotificationOutboxQueryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
StatusFilter: NullIfEmpty(_statusFilter),
TypeFilter: NullIfEmpty(_typeFilter),
SourceSiteFilter: NullIfEmpty(_siteFilter),
ListNameFilter: NullIfEmpty(_listFilter),
StuckOnly: _stuckOnly,
SubjectKeyword: NullIfEmpty(_subjectFilter),
From: ToUtc(_fromFilter),
To: ToUtc(_toFilter),
PageNumber: _pageNumber,
PageSize: _pageSize,
SourceNodeFilter: NullIfEmpty(_nodeFilter));
var response = await CommunicationService.QueryNotificationOutboxAsync(request);
if (response.Success)
{
// CentralUI-028: drop any row whose source site is outside the
// user's permitted set. The query API accepts only a single
// SourceSiteFilter, so a scoped user with an empty filter could
// otherwise see every site's rows; this is the row-level safety
// net behind the dropdown restriction.
_notifications = await FilterPermittedAsync(response.Notifications);
_totalCount = response.TotalCount;
}
else
{
_listError = response.ErrorMessage ?? "Query failed.";
}
}
catch (Exception ex)
{
_listError = $"Query failed: {ex.Message}";
}
_loading = false;
}
private async Task RetryNotification(NotificationSummary n)
{
// CentralUI-028: server-side re-check before relaying — even if the row
// somehow made it into the grid for an out-of-scope user (race with a
// permission change, stale circuit cache), the relay must not fire.
if (!await IsRowSiteAllowedAsync(n.SourceSiteId))
{
_toast.ShowError("You are not permitted to act on notifications for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Retry notification",
$"Re-queue notification {ShortId(n.NotificationId)} (\"{n.Subject}\") for delivery?");
if (!confirmed) return;
_actionInProgress = true;
try
{
var response = await CommunicationService.RetryNotificationAsync(
new RetryNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId));
if (response.Success)
{
_toast.ShowSuccess($"Notification {ShortId(n.NotificationId)} re-queued for delivery.");
await RefreshAll();
}
else
{
_toast.ShowError(response.ErrorMessage ?? "Retry failed.");
}
}
catch (Exception ex)
{
_toast.ShowError($"Retry failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DiscardNotification(NotificationSummary n)
{
if (!await IsRowSiteAllowedAsync(n.SourceSiteId))
{
_toast.ShowError("You are not permitted to act on notifications for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Discard notification",
$"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.",
danger: true);
if (!confirmed) return;
_actionInProgress = true;
try
{
var response = await CommunicationService.DiscardNotificationAsync(
new DiscardNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId));
if (response.Success)
{
_toast.ShowSuccess($"Notification {ShortId(n.NotificationId)} discarded.");
await RefreshAll();
}
else
{
_toast.ShowError(response.ErrorMessage ?? "Discard failed.");
}
}
catch (Exception ex)
{
_toast.ShowError($"Discard failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task ShowDetail(NotificationSummary n)
{
// The summary fields render immediately; Body + recipients fill in once the
// full-detail fetch completes.
_detailNotification = n;
_detail = null;
_detailError = null;
_detailLoading = true;
StateHasChanged();
try
{
var response = await CommunicationService.GetNotificationDetailAsync(
new NotificationDetailRequest(Guid.NewGuid().ToString("N"), n.NotificationId));
if (response.Success && response.Detail != null)
{
_detail = response.Detail;
}
else
{
_detailError = response.ErrorMessage ?? "Failed to load notification detail.";
}
}
catch (Exception ex)
{
_detailError = $"Failed to load notification detail: {ex.Message}";
}
_detailLoading = false;
}
private void CloseDetail()
{
_detailNotification = null;
_detail = null;
_detailError = null;
_detailLoading = false;
}
/// <summary>
/// Best-effort parse of <c>ResolvedTargets</c> into individual recipient addresses.
/// The field may be a JSON string array, or a comma/semicolon-separated string.
/// Returns an empty list when null/empty.
/// </summary>
private static List<string> ParseRecipients(string? resolvedTargets)
{
if (string.IsNullOrWhiteSpace(resolvedTargets))
{
return new List<string>();
}
var trimmed = resolvedTargets.Trim();
if (trimmed.StartsWith('['))
{
try
{
var parsed = System.Text.Json.JsonSerializer.Deserialize<List<string>>(trimmed);
if (parsed != null)
{
return parsed
.Where(r => !string.IsNullOrWhiteSpace(r))
.Select(r => r.Trim())
.ToList();
}
}
catch (System.Text.Json.JsonException)
{
// Not valid JSON — fall through to the delimiter-split path.
}
}
return trimmed
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
private async Task RetryFromDetail(NotificationSummary n)
{
await RetryNotification(n);
// RefreshAll replaces the row list; close the modal so the user sees the
// refreshed grid rather than a now-stale detail snapshot.
CloseDetail();
}
private async Task DiscardFromDetail(NotificationSummary n)
{
await DiscardNotification(n);
CloseDetail();
}
private void ClearFilters()
{
_statusFilter = string.Empty;
_typeFilter = string.Empty;
_siteFilter = string.Empty;
_listFilter = string.Empty;
_subjectFilter = string.Empty;
_nodeFilter = string.Empty;
_stuckOnly = false;
_fromFilter = null;
_toFilter = null;
}
private bool HasActiveFilters =>
!string.IsNullOrEmpty(_statusFilter) ||
!string.IsNullOrEmpty(_typeFilter) ||
!string.IsNullOrEmpty(_siteFilter) ||
!string.IsNullOrEmpty(_listFilter) ||
!string.IsNullOrEmpty(_subjectFilter) ||
!string.IsNullOrEmpty(_nodeFilter) ||
_stuckOnly ||
_fromFilter != null ||
_toFilter != null;
private string SiteName(string siteId) =>
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;
private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
// CentralUI-027: <input type="datetime-local"> binds with DateTimeKind.Unspecified
// — the value is the operator's browser-local wall-clock. Tag it as Local and
// convert to UTC before the value enters the wire query; otherwise the From/To
// window is silently shifted by the operator's UTC offset.
private static DateTimeOffset? ToUtc(DateTime? local) =>
local.HasValue
? new DateTimeOffset(
DateTime.SpecifyKind(local.Value, DateTimeKind.Local).ToUniversalTime(),
TimeSpan.Zero)
: (DateTimeOffset?)null;
private static string ShortId(string id) => id[..Math.Min(12, id.Length)];
private static string StatusBadgeClass(string status) => status switch
{
"Delivered" => "bg-success",
"Parked" => "bg-danger",
"Retrying" => "bg-warning text-dark",
"Pending" => "bg-info text-dark",
"Discarded" => "bg-secondary",
_ => "bg-light text-dark"
};
/// <summary>
/// Drops any notification whose <c>SourceSiteId</c> resolves to a Site.Id outside
/// the caller's permitted set. A system-wide user gets the list back unchanged.
/// Lookup uses <c>_allSites</c> (NOT <c>_sites</c>) so a row whose Site was
/// filtered OUT of the dropdown is correctly classified as out-of-scope.
/// A truly unknown <c>SourceSiteId</c> (stale row from a deleted site) is kept —
/// there is no Site.Id to gate it on.
/// </summary>
private Task<List<NotificationSummary>> FilterPermittedAsync(
IEnumerable<NotificationSummary> notifications)
{
if (_siteScopeSystemWide)
return Task.FromResult(notifications.ToList());
var filtered = notifications
.Where(n =>
{
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == n.SourceSiteId);
return resolved is null || _permittedSiteIds.Contains(resolved.Id);
})
.ToList();
return Task.FromResult(filtered);
}
/// <summary>
/// Server-side re-check for the Retry/Discard relay actions. Returns true for a
/// system-wide user, or when the row's source site resolves to a Site.Id in the
/// caller's permitted set. An unresolvable site identifier defaults to allowed
/// (legacy behaviour); the relay's own site-scope re-check is then the
/// final gate.
/// </summary>
private bool IsRowSiteAllowedSync(string sourceSiteId)
{
if (_siteScopeSystemWide)
return true;
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSiteId);
if (resolved is null)
return true;
return _permittedSiteIds.Contains(resolved.Id);
}
private Task<bool> IsRowSiteAllowedAsync(string sourceSiteId)
=> Task.FromResult(IsRowSiteAllowedSync(sourceSiteId));
}
@@ -0,0 +1,235 @@
@page "/notifications/smtp"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using SmtpConfigurationEntity = ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">SMTP Configuration</h4>
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
@if (_smtpConfigs.Count == 0 && !_showForm)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No SMTP configuration set.</p>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">
Add SMTP configuration
</button>
</div>
}
else
{
@foreach (var smtp in _smtpConfigs)
{
<div class="card mb-3" @key="smtp.Id">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>@smtp.Host</strong>
@if (_editingSmtp?.Id != smtp.Id || !_showForm)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartEdit(smtp)">Edit</button>
}
</div>
<div class="card-body small">
<div class="row g-2">
<div class="col-md-4 text-muted">Host</div>
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
<div class="col-md-4 text-muted">Auth Type</div>
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
<div class="col-md-4 text-muted">TLS Mode</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
<div class="col-md-4 text-muted">From Address</div>
<div class="col-md-8">@smtp.FromAddress</div>
<div class="col-md-4 text-muted">Credentials</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
</div>
</div>
</div>
}
@if (_showForm)
{
<div class="card mb-3">
<div class="card-header">@(_editingSmtp != null ? "Edit SMTP Configuration" : "Add SMTP Configuration")</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Host</label>
<input type="text" class="form-control" @bind="_host" placeholder="smtp.example.com" />
</div>
<div class="col-md-4">
<label class="form-label">Port</label>
<input type="number" class="form-control" @bind="_port" min="1" max="65535" />
</div>
<div class="col-md-4">
<label class="form-label">Auth Type</label>
<select class="form-select" @bind="_authType">
<option>OAuth2</option>
<option>Basic</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">TLS Mode</label>
<select class="form-select" @bind="_tlsMode">
<option>None</option>
<option>StartTLS</option>
<option>SSL</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Credentials</label>
<input type="password" class="form-control" @bind="_credentials"
placeholder="OAuth2 client secret or SMTP password" />
<div class="form-text">Treat as sensitive — visible to admins only.</div>
</div>
<div class="col-12">
<label class="form-label">From Address</label>
<input type="email" class="form-control" @bind="_fromAddress"
placeholder="noreply@example.com" />
</div>
@if (_formError != null)
{
<div class="col-12"><div class="text-danger small">@_formError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
</div>
</div>
</div>
</div>
}
else if (_smtpConfigs.Count == 0)
{
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add SMTP configuration</button>
}
}
}
</div>
@code {
private bool _loading = true;
private string? _errorMessage;
private List<SmtpConfigurationEntity> _smtpConfigs = new();
private bool _showForm;
private SmtpConfigurationEntity? _editingSmtp;
private string _host = string.Empty;
private int _port = 587;
private string _authType = "OAuth2";
private string? _tlsMode;
private string? _credentials;
private string _fromAddress = string.Empty;
private string? _formError;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_errorMessage = null;
try
{
_smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList();
}
catch (Exception ex)
{
_errorMessage = ex.Message;
}
_loading = false;
}
private void ShowAddForm()
{
_editingSmtp = null;
_host = string.Empty;
_port = 587;
_authType = "OAuth2";
_tlsMode = "None";
_credentials = null;
_fromAddress = string.Empty;
_formError = null;
_showForm = true;
}
private void StartEdit(SmtpConfigurationEntity smtp)
{
_editingSmtp = smtp;
_host = smtp.Host;
_port = smtp.Port;
_authType = smtp.AuthType;
_tlsMode = smtp.TlsMode;
_credentials = smtp.Credentials;
_fromAddress = smtp.FromAddress;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_formError = null;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_host) || string.IsNullOrWhiteSpace(_fromAddress))
{
_formError = "Host and From Address are required.";
return;
}
try
{
if (_editingSmtp != null)
{
_editingSmtp.Host = _host.Trim();
_editingSmtp.Port = _port;
_editingSmtp.AuthType = _authType;
_editingSmtp.TlsMode = _tlsMode;
_editingSmtp.Credentials = _credentials?.Trim();
_editingSmtp.FromAddress = _fromAddress.Trim();
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
}
else
{
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
{
Port = _port,
TlsMode = _tlsMode,
Credentials = _credentials?.Trim()
};
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
}
await NotificationRepository.SaveChangesAsync();
_showForm = false;
_toast.ShowSuccess("SMTP configuration saved.");
await LoadAsync();
}
catch (Exception ex)
{
_formError = ex.Message;
}
}
}
@@ -0,0 +1,337 @@
@page "/site-calls/report"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit
@using ZB.MOM.WW.ScadaBridge.Communication
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject IDialogService Dialog
@inject ILogger<SiteCallsReport> Logger
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Calls</h4>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Refresh
</button>
</div>
@* ── Filters ── *@
<div class="card mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" for="sc-status">Status</label>
<select id="sc-status" class="form-select form-select-sm" style="min-width: 130px;"
@bind="_statusFilter">
<option value="">All</option>
<option value="Submitted">Submitted</option>
<option value="Forwarded">Forwarded</option>
<option value="Attempted">Attempted</option>
<option value="Delivered">Delivered</option>
<option value="Parked">Parked</option>
<option value="Failed">Failed</option>
<option value="Discarded">Discarded</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-channel">Channel</label>
<select id="sc-channel" class="form-select form-select-sm" style="min-width: 130px;"
@bind="_channelFilter">
<option value="">All</option>
<option value="ApiOutbound">ApiOutbound</option>
<option value="DbOutbound">DbOutbound</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-site">Source site</label>
<select id="sc-site" class="form-select form-select-sm" style="min-width: 150px;"
@bind="_siteFilter">
<option value="">Any</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</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"
@bind="_fromFilter" />
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-to">To</label>
<input id="sc-to" type="datetime-local" class="form-control form-control-sm"
@bind="_toFilter" />
</div>
<div class="col">
<label class="form-label small mb-1" for="sc-search">Target keyword</label>
<input id="sc-search" type="search" class="form-control form-control-sm"
placeholder="Exact target…" @bind="_targetFilter" />
</div>
<div class="col-auto">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="sc-stuck-only"
@bind="_stuckOnly" />
<label class="form-check-label small" for="sc-stuck-only">Stuck only</label>
</div>
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
disabled="@(!HasActiveFilters)">Clear</button>
</div>
<div class="col-auto">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_loading"
data-test="site-calls-query">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Query
</button>
</div>
</div>
</div>
</div>
@if (_listError != null)
{
<div class="alert alert-danger">@_listError</div>
}
@* ── Site call list ── *@
@if (_siteCalls == null)
{
@if (_loading)
{
<div class="text-muted small">Loading…</div>
}
}
else if (_siteCalls.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-5">
<div class="fs-5 mb-1">No site calls</div>
<div class="small">No cached calls match the current filters.</div>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Tracked operation</th>
<th>Source site</th>
<th>Node</th>
<th>Channel</th>
<th>Target</th>
<th>Status</th>
<th class="text-end">Retries</th>
<th>Last error</th>
<th>Created</th>
<th>Updated</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var c in _siteCalls)
{
<tr @key="c.TrackedOperationId" class="@(c.IsStuck ? "table-warning" : "")"
style="cursor: pointer;" @ondblclick="() => ShowDetail(c)"
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>
<span class="badge @StatusBadgeClass(c.Status)">@c.Status</span>
@if (c.IsStuck)
{
<span class="badge bg-warning text-dark ms-1">Stuck</span>
}
</td>
<td class="text-end font-monospace">@c.RetryCount</td>
<td>
@if (!string.IsNullOrEmpty(c.LastError))
{
<div class="small text-danger text-truncate" style="max-width: 280px;"
title="@c.LastError">@c.LastError</div>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td><TimestampDisplay Value="@AsOffset(c.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
<td><TimestampDisplay Value="@AsOffset(c.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
<td class="text-end" @ondblclick:stopPropagation="true">
@* The TrackedOperationId is the audit CorrelationId, so the
link deep-links into the central Audit Log pre-filtered to
this cached call's lifecycle events. *@
<a class="btn btn-outline-secondary btn-sm me-1"
href="/audit/log?correlationId=@c.TrackedOperationId"
data-test="audit-link-@c.TrackedOperationId">
View audit history
</a>
@* Retry/Discard relay only on Parked rows — central relays the
action to the owning site; Failed and other statuses are not
actionable from central. *@
@if (c.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm me-1"
@onclick="() => RetrySiteCall(c)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardSiteCall(c)" disabled="@_actionInProgress">
Discard
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
@* Keyset paging — the Task 4 query response carries a (CreatedAtUtc, Id)
cursor rather than page numbers, so we keep a stack of cursors to step
backwards and the response's NextAfter* cursor to step forwards. *@
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">
@* No "of N" total: keyset paging has no cheap total-count, so
the label is intentionally page-number-only. Do not "fix"
this by adding a total — that would require a COUNT(*). *@
Page @(_cursorStack.Count + 1) · @_siteCalls.Count rows
</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="PrevPage" disabled="@(_cursorStack.Count == 0 || _loading)"
data-test="site-calls-prev">Previous</button>
<button class="btn btn-outline-secondary btn-sm"
@onclick="NextPage" disabled="@(!HasNextPage || _loading)"
data-test="site-calls-next">Next</button>
</div>
</div>
}
</div>
@* ── Row detail modal ── *@
@if (_detailSiteCall != null)
{
var d = _detailSiteCall;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Site Call Detail — @ShortId(d.TrackedOperationId)</h6>
<button type="button" class="btn-close" aria-label="Close"
@onclick="CloseDetail"></button>
</div>
<div class="modal-body">
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
var det = _detail;
<dl class="row mb-0">
<dt class="col-sm-3">Tracked operation</dt>
<dd class="col-sm-9"><code>@det.TrackedOperationId</code></dd>
<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>
<dt class="col-sm-3">Target</dt>
<dd class="col-sm-9">@det.Target</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge @StatusBadgeClass(det.Status)">@det.Status</span>
</dd>
<dt class="col-sm-3">Retry count</dt>
<dd class="col-sm-9 font-monospace">@det.RetryCount</dd>
<dt class="col-sm-3">HTTP status</dt>
<dd class="col-sm-9">@(det.HttpStatus?.ToString() ?? "—")</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
</dd>
<dt class="col-sm-3">Updated</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
</dd>
<dt class="col-sm-3">Terminal</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.TerminalAtUtc)"
Format="yyyy-MM-dd HH:mm:ss" NullText="—" />
</dd>
<dt class="col-sm-3">Ingested (central)</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.IngestedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
</dd>
@if (!string.IsNullOrEmpty(det.LastError))
{
<dt class="col-sm-3">Last error</dt>
@* Plain text — never a MarkupString. *@
<dd class="col-sm-9 text-danger">@det.LastError</dd>
}
</dl>
}
</div>
<div class="modal-footer">
@if (d.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
}
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,524 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls;
/// <summary>
/// Code-behind for the central Site Calls report page (Site Call Audit #22). A
/// near-mirror of <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationReport"/>:
/// it queries the central <c>SiteCalls</c> table via
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService.QuerySiteCallsAsync"/>,
/// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard
/// of <c>Parked</c> cached calls to their owning site.
///
/// <para>
/// Unlike the Notification report, the query response uses a <c>(CreatedAtUtc DESC,
/// TrackedOperationId DESC)</c> keyset cursor rather than page numbers, so paging
/// keeps a stack of the cursors that opened each page (to step backwards) plus the
/// response's <c>NextAfter*</c> cursor (to step forwards).
/// </para>
///
/// <para>
/// Retry/Discard relay to the owning site has a distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/>
/// outcome — central is an eventually-consistent mirror, not the source of truth, so
/// a relay that never reaches the site is a transient transport condition, surfaced
/// to the operator differently from a generic failure.
/// </para>
///
/// <para>
/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here
/// with <c>?status=Parked</c> (Parked tile) or <c>?stuck=true</c> (Stuck tile). On
/// initialization those params seed <see cref="_statusFilter"/> / <see cref="_stuckOnly"/>
/// BEFORE the first <see cref="RefreshAll"/>, so the first grid load is already
/// filtered and the filter card controls reflect the seeded values. Parsing is lax
/// — an absent, blank, or unrecognised value is silently dropped and the page loads
/// unfiltered, mirroring <c>AuditLogPage</c>'s drill-in convention.
/// </para>
/// </summary>
public partial class SiteCallsReport
{
private const int PageSize = 50;
[Inject] private NavigationManager Navigation { get; set; } = null!;
[Inject] private SiteScopeService SiteScope { get; set; } = null!;
// The Status filter <select> options — the exact strings the dropdown binds and
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
// filter when it matches one of these (case-insensitively); anything else is
// dropped so a hand-crafted bad URL still renders the page unfiltered.
private static readonly string[] ValidStatuses =
{
"Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
};
private ToastNotification _toast = default!;
private List<Site> _sites = new();
// CentralUI-028: unfiltered site list so a permitted-site lookup resolves
// correctly for a SourceSite whose Site was filtered out of the dropdown.
private List<Site> _allSites = new();
private bool _siteScopeSystemWide;
private HashSet<int> _permittedSiteIds = new();
// List
private List<SiteCallSummary>? _siteCalls;
private bool _loading;
private string? _listError;
private bool _actionInProgress;
// Keyset paging. The first page is opened with the empty (null, null) cursor.
// _cursorStack holds the cursors of the PREVIOUSLY visited pages — it is empty
// on page 1, has one entry on page 2, and so on; Previous pops it. _nextCursor
// is the cursor for the following page, echoed back by the last query.
private readonly Stack<(DateTime? AfterCreatedAtUtc, Guid? AfterId)> _cursorStack = new();
private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null);
private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor;
// Row detail modal
private SiteCallSummary? _detailSiteCall;
private SiteCallDetail? _detail;
private bool _detailLoading;
private string? _detailError;
// Filters
private string _statusFilter = string.Empty;
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;
private bool HasNextPage => _nextCursor is not null;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
try
{
_allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
// CentralUI-028: restrict the source-site dropdown to the user's
// permitted set. System-wide users see the full list back unchanged.
_sites = await SiteScope.FilterSitesAsync(_allSites);
_siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
_permittedSiteIds = new HashSet<int>(await SiteScope.PermittedSiteIdsAsync());
}
catch (Exception ex)
{
// Non-fatal — the source-site filter just falls back to raw site IDs.
Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter.");
}
// Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
// grid load is already filtered (and the filter card controls reflect it).
ApplyQueryStringFilters();
await RefreshAll();
}
/// <summary>
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
/// string. <c>?status=&lt;status&gt;</c> seeds <see cref="_statusFilter"/> when it
/// matches a known status (case-insensitive); <c>?stuck=true</c> seeds
/// <see cref="_stuckOnly"/>. Lax parsing — an absent, blank, or unrecognised value
/// is silently dropped, leaving the filter empty (the no-param behaviour).
/// </summary>
private void ApplyQueryStringFilters()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.Count == 0)
{
return;
}
if (query.TryGetValue("status", out var statusValues))
{
var v = statusValues.ToString();
// Round-trip the dropdown's own option strings (the KPI tile emits the
// canonical casing, e.g. ?status=Parked); normalise to that casing so the
// <select> binds. An unrecognised value leaves the filter unset.
var match = ValidStatuses.FirstOrDefault(
s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
_statusFilter = match;
}
}
if (query.TryGetValue("stuck", out var stuckValues)
&& bool.TryParse(stuckValues.ToString(), out var stuck))
{
_stuckOnly = stuck;
}
}
/// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
private async Task RefreshAll()
{
await FetchPage(_currentCursor);
}
/// <summary>Apply the filters and start again from the first page.</summary>
private async Task Search()
{
_cursorStack.Clear();
await FetchPage((null, null));
}
private async Task PrevPage()
{
if (_cursorStack.Count == 0)
{
return;
}
// The top of the stack is the cursor of the page BEFORE the current one.
var previousCursor = _cursorStack.Pop();
await FetchPage(previousCursor);
}
private async Task NextPage()
{
if (_nextCursor is not { } next)
{
return;
}
// Stepping forward: remember the current page's cursor so Previous can
// return to it.
_cursorStack.Push(_currentCursor);
await FetchPage(next);
}
/// <summary>
/// Fetch one keyset page starting after <paramref name="cursor"/>.
/// </summary>
private async Task FetchPage(
(DateTime? AfterCreatedAtUtc, Guid? AfterId) cursor)
{
_loading = true;
_listError = null;
try
{
var request = new SiteCallQueryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
StatusFilter: NullIfEmpty(_statusFilter),
SourceSiteFilter: NullIfEmpty(_siteFilter),
ChannelFilter: NullIfEmpty(_channelFilter),
TargetKeyword: NullIfEmpty(_targetFilter),
StuckOnly: _stuckOnly,
FromUtc: ToUtc(_fromFilter),
ToUtc: ToUtc(_toFilter),
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
AfterId: cursor.AfterId,
PageSize: PageSize,
SourceNodeFilter: NullIfEmpty(_nodeFilter));
var response = await CommunicationService.QuerySiteCallsAsync(request);
if (response.Success)
{
// CentralUI-028: drop any row whose source site is outside the
// user's permitted set, as a row-level safety net behind the
// dropdown restriction.
_siteCalls = await FilterPermittedAsync(response.SiteCalls);
_currentCursor = cursor;
// The response echoes the last row's cursor. A short page (fewer
// rows than requested) has no further page even if a cursor came
// back, so gate Next on a full page too.
_nextCursor = response.NextAfterCreatedAtUtc is { } nextCreated
&& response.NextAfterId is { } nextId
&& _siteCalls.Count == PageSize
? (nextCreated, nextId)
: null;
}
else
{
_listError = response.ErrorMessage ?? "Query failed.";
}
}
catch (Exception ex)
{
_listError = $"Query failed: {ex.Message}";
}
_loading = false;
}
private async Task RetrySiteCall(SiteCallSummary c)
{
// CentralUI-028: server-side re-check before relaying — a Retry relay must
// not fire for a site outside the caller's permitted set, even if the row
// somehow appeared in the grid.
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Retry cached call",
$"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
$"to site {SiteName(c.SourceSite)}?");
if (!confirmed) return;
_actionInProgress = true;
try
{
var response = await CommunicationService.RetrySiteCallAsync(
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
appliedMessage: $"Retry of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
if (response.Success)
{
await RefreshAll();
}
}
catch (Exception ex)
{
_toast.ShowError($"Retry failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DiscardSiteCall(SiteCallSummary c)
{
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Discard cached call",
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
$"to site {SiteName(c.SourceSite)}? This cannot be undone.",
danger: true);
if (!confirmed) return;
_actionInProgress = true;
try
{
var response = await CommunicationService.DiscardSiteCallAsync(
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
appliedMessage: $"Discard of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
if (response.Success)
{
await RefreshAll();
}
}
catch (Exception ex)
{
_toast.ShowError($"Discard failed: {ex.Message}");
}
_actionInProgress = false;
}
/// <summary>
/// Surface a relay outcome on the toast — exactly one toast per relay
/// response. The <see cref="SiteCallRelayOutcome.SiteUnreachable"/> case is
/// deliberately distinct from a generic failure: the action was not applied
/// but the operator can retry once the site is back online.
/// </summary>
/// <remarks>
/// The <see cref="SiteCallRelayOutcome"/> switch is exhaustive, so it owns
/// the single toast. <paramref name="siteReachable"/> is a redundant
/// cross-check on the same signal (the contract sets it <c>false</c> only
/// for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>); it is folded
/// INTO the <see cref="SiteCallRelayOutcome.OperationFailed"/> case rather
/// than firing a second toast — an <c>OperationFailed</c> response that also
/// reports an unreachable site shows the unreachable wording, once.
/// </remarks>
private void ShowRelayOutcome(
SiteCallRelayOutcome outcome, bool siteReachable, string? errorMessage, string appliedMessage)
{
switch (outcome)
{
case SiteCallRelayOutcome.Applied:
_toast.ShowSuccess(appliedMessage);
break;
case SiteCallRelayOutcome.NotParked:
_toast.ShowInfo(errorMessage
?? "The site reported nothing to do — the cached call is no longer parked.");
break;
case SiteCallRelayOutcome.SiteUnreachable:
_toast.ShowError(errorMessage
?? "Site unreachable — the relay did not reach the owning site. "
+ "Try again once the site is back online.");
break;
case SiteCallRelayOutcome.OperationFailed when !siteReachable:
// An OperationFailed response that nonetheless reports the site
// unreachable: trust the reachability signal and show the
// unreachable wording instead of the generic failure message.
_toast.ShowError(errorMessage
?? "Site unreachable — the relay did not reach the owning site. "
+ "Try again once the site is back online.");
break;
case SiteCallRelayOutcome.OperationFailed:
default:
_toast.ShowError(errorMessage ?? "The site could not apply the action.");
break;
}
}
private async Task ShowDetail(SiteCallSummary c)
{
// The summary fields render immediately from the grid row; the full detail
// (HttpStatus, all timestamps, LastError) fills in once the fetch completes.
_detailSiteCall = c;
_detail = null;
_detailError = null;
_detailLoading = true;
StateHasChanged();
try
{
var response = await CommunicationService.GetSiteCallDetailAsync(
new SiteCallDetailRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId));
if (response.Success && response.Detail != null)
{
_detail = response.Detail;
}
else
{
_detailError = response.ErrorMessage ?? "Failed to load site call detail.";
}
}
catch (Exception ex)
{
_detailError = $"Failed to load site call detail: {ex.Message}";
}
_detailLoading = false;
}
private void CloseDetail()
{
_detailSiteCall = null;
_detail = null;
_detailError = null;
_detailLoading = false;
}
private async Task RetryFromDetail(SiteCallSummary c)
{
await RetrySiteCall(c);
// RefreshAll replaces the row list; close the modal so the user sees the
// refreshed grid rather than a now-stale detail snapshot.
CloseDetail();
}
private async Task DiscardFromDetail(SiteCallSummary c)
{
await DiscardSiteCall(c);
CloseDetail();
}
private void ClearFilters()
{
_statusFilter = string.Empty;
_channelFilter = string.Empty;
_siteFilter = string.Empty;
_targetFilter = string.Empty;
_nodeFilter = string.Empty;
_stuckOnly = false;
_fromFilter = null;
_toFilter = null;
}
private bool HasActiveFilters =>
!string.IsNullOrEmpty(_statusFilter) ||
!string.IsNullOrEmpty(_channelFilter) ||
!string.IsNullOrEmpty(_siteFilter) ||
!string.IsNullOrEmpty(_targetFilter) ||
!string.IsNullOrEmpty(_nodeFilter) ||
_stuckOnly ||
_fromFilter != null ||
_toFilter != null;
private string SiteName(string siteId) =>
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;
private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
/// <summary>
/// CentralUI-027: <c>&lt;input type="datetime-local"&gt;</c> binds with
/// <see cref="DateTimeKind.Unspecified"/> and the value is the operator's
/// browser-local wall-clock. Tag it <see cref="DateTimeKind.Local"/> and
/// convert to UTC before the value enters the wire query — otherwise the
/// From/To window is silently shifted by the operator's UTC offset.
/// </summary>
private static DateTime? ToUtc(DateTime? value) =>
value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime()
: (DateTime?)null;
/// <summary>
/// The <c>SiteCalls</c> timestamps are UTC <see cref="DateTime"/>; wrap them as
/// a <see cref="DateTimeOffset"/> for <c>TimestampDisplay</c>.
/// </summary>
private static DateTimeOffset? AsOffset(DateTime? value) =>
value == null
? null
: new DateTimeOffset(DateTime.SpecifyKind(value.Value, DateTimeKind.Utc));
// A Guid's "N" format is always exactly 32 hex chars, so the [..12] slice is
// always in range — no length guard needed.
private static string ShortId(Guid id) => id.ToString("N")[..12];
private static string StatusBadgeClass(string status) => status switch
{
"Delivered" => "bg-success",
"Parked" => "bg-danger",
"Failed" => "bg-danger",
"Attempted" => "bg-warning text-dark",
"Forwarded" => "bg-info text-dark",
"Submitted" => "bg-info text-dark",
"Discarded" => "bg-secondary",
_ => "bg-light text-dark"
};
/// <summary>
/// Drops any site-call row whose source site resolves to a Site.Id outside the
/// caller's permitted set. System-wide users get the list back unchanged.
/// Lookup uses <c>_allSites</c> (not <c>_sites</c>) so a row whose Site was
/// filtered OUT of the dropdown is correctly classified as out-of-scope.
/// </summary>
private Task<List<SiteCallSummary>> FilterPermittedAsync(
IEnumerable<SiteCallSummary> calls)
{
if (_siteScopeSystemWide)
return Task.FromResult(calls.ToList());
var filtered = calls
.Where(c =>
{
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == c.SourceSite);
return resolved is null || _permittedSiteIds.Contains(resolved.Id);
})
.ToList();
return Task.FromResult(filtered);
}
/// <summary>
/// Server-side re-check for the Retry/Discard relay. True for a system-wide
/// user, or when the row's source site maps to a Site.Id in the permitted set.
/// </summary>
private Task<bool> IsRowSiteAllowedAsync(string sourceSite)
{
if (_siteScopeSystemWide)
return Task.FromResult(true);
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
if (resolved is null)
return Task.FromResult(true);
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
}
}
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// One option in the alarm trigger editor's attribute picker.
/// <see cref="Source"/> is one of "Direct", "Inherited", or "Composed" —
/// used to group entries in the dropdown.
/// </summary>
public record AlarmAttributeChoice(string CanonicalName, string DataType, string Source);
@@ -0,0 +1,345 @@
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Round-trip codec for the alarm trigger configuration JSON used by both
/// <see cref="AlarmTriggerEditor"/> (UI editing) and AlarmActor (runtime
/// evaluation). The serialized shape per trigger type:
/// ValueMatch { attributeName, matchValue } ("!=X" prefix = not equals)
/// RangeViolation { attributeName, min, max }
/// RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction }
/// HiLo { attributeName, loLo, lo, hi, hiHi,
/// loLoPriority, loPriority, hiPriority, hiHiPriority }
/// Expression { expression }
///
/// All HiLo setpoints and per-setpoint priorities are optional — any subset
/// is valid (e.g., only Hi/HiHi configured for over-temperature protection).
///
/// Parsing also accepts legacy aliases the runtime used to consume
/// (<c>attribute</c>, <c>value</c>, <c>low</c>, <c>high</c>) so older configs
/// survive a round-trip through the editor.
/// </summary>
internal static class AlarmTriggerConfigCodec
{
/// <summary>
/// Parses a trigger configuration JSON in the context of the given trigger
/// type. Returns a model with default values on null/empty/malformed input
/// or for missing keys — never throws.
/// </summary>
/// <param name="json">The trigger configuration JSON string, or null/empty for defaults.</param>
/// <param name="type">The alarm trigger type that determines which properties to extract.</param>
/// <returns>A populated AlarmTriggerModel with default values for missing fields.</returns>
internal static AlarmTriggerModel Parse(string? json, AlarmTriggerType type)
{
var model = new AlarmTriggerModel();
if (string.IsNullOrWhiteSpace(json)) return model;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
model.AttributeName =
root.TryGetProperty("attributeName", out var a) ? a.GetString()
: root.TryGetProperty("attribute", out var a2) ? a2.GetString()
: null;
switch (type)
{
case AlarmTriggerType.ValueMatch:
{
var raw = root.TryGetProperty("matchValue", out var mv) ? mv.GetString()
: root.TryGetProperty("value", out var mv2) ? mv2.GetString()
: null;
if (raw != null && raw.StartsWith("!=", StringComparison.Ordinal))
{
model.NotEquals = true;
model.MatchValue = raw[2..];
}
else
{
model.MatchValue = raw;
}
break;
}
case AlarmTriggerType.RangeViolation:
model.Min = TryReadDouble(root, "min") ?? TryReadDouble(root, "low");
model.Max = TryReadDouble(root, "max") ?? TryReadDouble(root, "high");
break;
case AlarmTriggerType.RateOfChange:
model.ThresholdPerSecond = TryReadDouble(root, "thresholdPerSecond");
model.WindowSeconds = TryReadDouble(root, "windowSeconds");
var dir = root.TryGetProperty("direction", out var d) ? d.GetString() : null;
model.Direction = NormalizeDirection(dir);
break;
case AlarmTriggerType.HiLo:
model.LoLo = TryReadDouble(root, "loLo");
model.Lo = TryReadDouble(root, "lo");
model.Hi = TryReadDouble(root, "hi");
model.HiHi = TryReadDouble(root, "hiHi");
model.LoLoPriority = TryReadInt(root, "loLoPriority");
model.LoPriority = TryReadInt(root, "loPriority");
model.HiPriority = TryReadInt(root, "hiPriority");
model.HiHiPriority = TryReadInt(root, "hiHiPriority");
model.LoLoDeadband = TryReadDouble(root, "loLoDeadband");
model.LoDeadband = TryReadDouble(root, "loDeadband");
model.HiDeadband = TryReadDouble(root, "hiDeadband");
model.HiHiDeadband = TryReadDouble(root, "hiHiDeadband");
model.LoLoMessage = TryReadString(root, "loLoMessage");
model.LoMessage = TryReadString(root, "loMessage");
model.HiMessage = TryReadString(root, "hiMessage");
model.HiHiMessage = TryReadString(root, "hiHiMessage");
break;
case AlarmTriggerType.Expression:
model.Expression = TryReadString(root, "expression");
break;
}
}
catch (JsonException)
{
// Malformed JSON — fall through with default model.
}
return model;
}
/// <summary>
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
/// expects. Writes <c>attributeName</c> (canonical key) for the
/// attribute-bound trigger types and only the keys relevant to the
/// current trigger type. <c>Expression</c> is not bound to a single
/// attribute, so <c>attributeName</c> is omitted for it.
/// </summary>
/// <param name="model">The AlarmTriggerModel to serialize.</param>
/// <param name="type">The alarm trigger type determining which properties to serialize.</param>
/// <returns>The serialized JSON representation of the model.</returns>
internal static string Serialize(AlarmTriggerModel model, AlarmTriggerType type)
{
using var stream = new MemoryStream();
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartObject();
if (type != AlarmTriggerType.Expression)
w.WriteString("attributeName", model.AttributeName ?? "");
switch (type)
{
case AlarmTriggerType.ValueMatch:
var mv = model.MatchValue ?? "";
if (model.NotEquals) mv = "!=" + mv;
w.WriteString("matchValue", mv);
break;
case AlarmTriggerType.RangeViolation:
if (model.Min.HasValue) w.WriteNumber("min", model.Min.Value);
if (model.Max.HasValue) w.WriteNumber("max", model.Max.Value);
break;
case AlarmTriggerType.RateOfChange:
if (model.ThresholdPerSecond.HasValue)
w.WriteNumber("thresholdPerSecond", model.ThresholdPerSecond.Value);
if (model.WindowSeconds.HasValue)
w.WriteNumber("windowSeconds", model.WindowSeconds.Value);
w.WriteString("direction", model.Direction);
break;
case AlarmTriggerType.HiLo:
if (model.LoLo.HasValue) w.WriteNumber("loLo", model.LoLo.Value);
if (model.Lo.HasValue) w.WriteNumber("lo", model.Lo.Value);
if (model.Hi.HasValue) w.WriteNumber("hi", model.Hi.Value);
if (model.HiHi.HasValue) w.WriteNumber("hiHi", model.HiHi.Value);
if (model.LoLoPriority.HasValue) w.WriteNumber("loLoPriority", model.LoLoPriority.Value);
if (model.LoPriority.HasValue) w.WriteNumber("loPriority", model.LoPriority.Value);
if (model.HiPriority.HasValue) w.WriteNumber("hiPriority", model.HiPriority.Value);
if (model.HiHiPriority.HasValue) w.WriteNumber("hiHiPriority", model.HiHiPriority.Value);
if (model.LoLoDeadband.HasValue) w.WriteNumber("loLoDeadband", model.LoLoDeadband.Value);
if (model.LoDeadband.HasValue) w.WriteNumber("loDeadband", model.LoDeadband.Value);
if (model.HiDeadband.HasValue) w.WriteNumber("hiDeadband", model.HiDeadband.Value);
if (model.HiHiDeadband.HasValue) w.WriteNumber("hiHiDeadband", model.HiHiDeadband.Value);
if (!string.IsNullOrEmpty(model.LoLoMessage)) w.WriteString("loLoMessage", model.LoLoMessage);
if (!string.IsNullOrEmpty(model.LoMessage)) w.WriteString("loMessage", model.LoMessage);
if (!string.IsNullOrEmpty(model.HiMessage)) w.WriteString("hiMessage", model.HiMessage);
if (!string.IsNullOrEmpty(model.HiHiMessage)) w.WriteString("hiHiMessage", model.HiHiMessage);
break;
case AlarmTriggerType.Expression:
w.WriteString("expression", model.Expression ?? "");
break;
}
w.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
/// <summary>
/// Normalizes a direction string to one of: rising, falling, or either.
/// </summary>
/// <param name="raw">The raw direction string to normalize.</param>
/// <returns>Normalized direction: "rising", "falling", or "either".</returns>
internal static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch
{
"rising" or "up" or "positive" => "rising",
"falling" or "down" or "negative" => "falling",
_ => "either"
};
private static double? TryReadDouble(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number => p.GetDouble(),
JsonValueKind.String when double.TryParse(p.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
private static int? TryReadInt(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number when p.TryGetInt32(out var i) => i,
JsonValueKind.Number => (int)p.GetDouble(),
JsonValueKind.String when int.TryParse(p.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
private static string? TryReadString(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind == JsonValueKind.String ? p.GetString() : null;
}
}
internal sealed class AlarmTriggerModel
{
/// <summary>
/// The attribute name bound to this trigger.
/// </summary>
public string? AttributeName { get; set; }
// ValueMatch
/// <summary>
/// The value to match against the attribute for ValueMatch triggers.
/// </summary>
public string? MatchValue { get; set; }
/// <summary>
/// Indicates whether the match should be inverted (not equal) for ValueMatch triggers.
/// </summary>
public bool NotEquals { get; set; }
// RangeViolation
/// <summary>
/// The minimum threshold for RangeViolation triggers.
/// </summary>
public double? Min { get; set; }
/// <summary>
/// The maximum threshold for RangeViolation triggers.
/// </summary>
public double? Max { get; set; }
// RateOfChange
/// <summary>
/// The threshold per second for RateOfChange triggers.
/// </summary>
public double? ThresholdPerSecond { get; set; }
/// <summary>
/// The time window in seconds for RateOfChange rate calculation.
/// </summary>
public double? WindowSeconds { get; set; }
/// <summary>
/// The direction of change: "rising", "falling", or "either" for RateOfChange triggers.
/// </summary>
public string Direction { get; set; } = "either";
// HiLo — any subset of setpoints may be set; per-setpoint priorities
// override the alarm-level priority for that band.
/// <summary>
/// The low-low setpoint for HiLo triggers.
/// </summary>
public double? LoLo { get; set; }
/// <summary>
/// The low setpoint for HiLo triggers.
/// </summary>
public double? Lo { get; set; }
/// <summary>
/// The high setpoint for HiLo triggers.
/// </summary>
public double? Hi { get; set; }
/// <summary>
/// The high-high setpoint for HiLo triggers.
/// </summary>
public double? HiHi { get; set; }
/// <summary>
/// The priority for low-low alarm state.
/// </summary>
public int? LoLoPriority { get; set; }
/// <summary>
/// The priority for low alarm state.
/// </summary>
public int? LoPriority { get; set; }
/// <summary>
/// The priority for high alarm state.
/// </summary>
public int? HiPriority { get; set; }
/// <summary>
/// The priority for high-high alarm state.
/// </summary>
public int? HiHiPriority { get; set; }
// Hysteresis: optional deactivation deadband per setpoint. Once at the
// band, the setpoint threshold is relaxed by this amount before the alarm
// de-escalates. Prevents flapping when the value hovers at the boundary.
/// <summary>
/// The deadband for low-low alarm de-escalation.
/// </summary>
public double? LoLoDeadband { get; set; }
/// <summary>
/// The deadband for low alarm de-escalation.
/// </summary>
public double? LoDeadband { get; set; }
/// <summary>
/// The deadband for high alarm de-escalation.
/// </summary>
public double? HiDeadband { get; set; }
/// <summary>
/// The deadband for high-high alarm de-escalation.
/// </summary>
public double? HiHiDeadband { get; set; }
// Per-band operator message. Optional; surfaces on AlarmStateChanged.Message
// and may be used by notification routing or operator displays.
/// <summary>
/// The operator message for low-low alarm state.
/// </summary>
public string? LoLoMessage { get; set; }
/// <summary>
/// The operator message for low alarm state.
/// </summary>
public string? LoMessage { get; set; }
/// <summary>
/// The operator message for high alarm state.
/// </summary>
public string? HiMessage { get; set; }
/// <summary>
/// The operator message for high-high alarm state.
/// </summary>
public string? HiHiMessage { get; set; }
// Expression — boolean C# expression evaluated on attribute updates.
/// <summary>
/// The boolean C# expression to evaluate for Expression triggers.
/// </summary>
public string? Expression { get; set; }
}
@@ -0,0 +1,646 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using System.Globalization
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Rich alarm trigger configuration editor. Replaces the raw JSON text field
used for TemplateAlarm.TriggerConfiguration. The editor emits the same JSON
shape that AlarmActor.ParseEvalConfig consumes:
ValueMatch { attributeName, matchValue } ("!=X" prefix = not equals)
RangeViolation { attributeName, min, max }
RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction } *@
<div class="border rounded bg-white p-3">
@* ── Monitored attribute ───────────────────────────────────────────── *@
@* Expression triggers reference attributes inside the C# expression itself,
so they do not use the single-attribute picker. *@
@if (TriggerType != AlarmTriggerType.Expression)
{
<div class="mb-3">
<label for="alarm-attr-select" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Monitored attribute
</label>
<div class="input-group input-group-sm">
<select id="alarm-attr-select"
class="form-select"
@bind="_attributeName"
@bind:after="OnAttributeChanged">
<option value="">— select attribute —</option>
@{
var groups = AvailableAttributes
.GroupBy(c => c.Source)
.OrderBy(g => SourceOrder(g.Key))
.ToList();
}
@foreach (var grp in groups)
{
<optgroup label="@grp.Key">
@foreach (var choice in grp.OrderBy(c => c.CanonicalName, StringComparer.Ordinal))
{
var label = $"{choice.CanonicalName} ({choice.DataType})";
var disabled = !IsAttributeCompatible(choice);
<option value="@choice.CanonicalName" disabled="@disabled">@label</option>
}
</optgroup>
}
@* If the saved attribute name isn't in the current list, keep it selectable so it's visible. *@
@if (!string.IsNullOrEmpty(_model.AttributeName) && _selectedChoice == null)
{
<optgroup label="Unknown">
<option value="@_model.AttributeName">@_model.AttributeName (not found)</option>
</optgroup>
}
</select>
@if (_selectedDataType is { } dt)
{
<span class="input-group-text bg-light text-muted small">@dt</span>
}
</div>
@if (_selectedChoice != null && !IsAttributeCompatible(_selectedChoice))
{
<div class="form-text text-danger">
Selected attribute is @_selectedChoice.DataType — this trigger type requires a numeric attribute.
</div>
}
else if (_selectedChoice == null && !string.IsNullOrWhiteSpace(_model.AttributeName))
{
<div class="form-text text-warning-emphasis">
"@_model.AttributeName" is not in the current template. Save will still write it as-is.
</div>
}
</div>
}
@* ── Type-specific block ───────────────────────────────────────────── *@
@switch (TriggerType)
{
case AlarmTriggerType.ValueMatch:
@RenderValueMatch();
break;
case AlarmTriggerType.RangeViolation:
@RenderRangeViolation();
break;
case AlarmTriggerType.RateOfChange:
@RenderRateOfChange();
break;
case AlarmTriggerType.HiLo:
@RenderHiLo();
break;
case AlarmTriggerType.Expression:
@RenderExpression();
break;
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
<div class="mt-3 pt-2 border-top small text-muted">
@BuildHint()
</div>
</div>
@code {
// ── Parameters ─────────────────────────────────────────────────────────
[Parameter] public AlarmTriggerType TriggerType { get; set; }
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
/// <summary>
/// Flattened attribute list (direct + inherited + composed). Used to drive
/// the picker and to determine the selected attribute's data type for
/// type-aware inputs.
/// </summary>
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; } =
Array.Empty<AlarmAttributeChoice>();
// ── Internal state ─────────────────────────────────────────────────────
private AlarmTriggerModel _model = new AlarmTriggerModel();
private AlarmTriggerType _lastSeenType;
private string? _lastSeenJson;
/// <summary>The choice currently selected from <see cref="AvailableAttributes"/>, if any.</summary>
private AlarmAttributeChoice? _selectedChoice;
private string? _selectedDataType => _selectedChoice?.DataType;
// ── Parse / serialize lifecycle ────────────────────────────────────────
protected override void OnParametersSet()
{
var typeChanged = _lastSeenType != TriggerType;
var jsonChanged = Value != _lastSeenJson;
if (!typeChanged && !jsonChanged) return;
_lastSeenType = TriggerType;
_lastSeenJson = Value;
// Preserve attribute name across type changes — re-parse the JSON in
// the context of the new type. Missing/unparseable keys fall back to
// empty defaults.
var preservedAttr = _model.AttributeName;
_model = AlarmTriggerConfigCodec.Parse(Value, TriggerType);
if (jsonChanged == false && typeChanged && !string.IsNullOrEmpty(preservedAttr))
_model.AttributeName = preservedAttr;
RefreshSelectedChoice();
SyncTextMirrors();
}
private void RefreshSelectedChoice()
{
_selectedChoice = AvailableAttributes.FirstOrDefault(
c => string.Equals(c.CanonicalName, _model.AttributeName, StringComparison.Ordinal));
}
private async Task Emit()
{
var json = AlarmTriggerConfigCodec.Serialize(_model, TriggerType);
_lastSeenJson = json;
await ValueChanged.InvokeAsync(json);
}
// ── Attribute picker ───────────────────────────────────────────────────
/// <summary>
/// String mirror for the attribute picker — required because @bind needs a
/// settable backing field, not a computed expression.
/// </summary>
private string _attributeName = string.Empty;
private async Task OnAttributeChanged()
{
_model.AttributeName = _attributeName;
RefreshSelectedChoice();
await Emit();
}
private static int SourceOrder(string source) => source switch
{
"Direct" => 0,
"Inherited" => 1,
"Composed" => 2,
_ => 3
};
private bool IsAttributeCompatible(AlarmAttributeChoice choice) =>
TriggerType == AlarmTriggerType.ValueMatch
|| IsNumericType(choice.DataType);
private static bool IsNumericType(string dataType) => dataType switch
{
"Integer" or "Int32" or "Int64" or "Float" or "Double" or "Number" => true,
_ => false
};
// ── ValueMatch ─────────────────────────────────────────────────────────
private RenderFragment RenderValueMatch() => __builder =>
{
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Operator
</label>
<select class="form-select form-select-sm"
@bind="_operatorText"
@bind:after="OnOperatorChanged">
<option value="eq">equals</option>
<option value="ne">not equals</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Match value
</label>
@{
var t = _selectedChoice?.DataType;
if (t == "Boolean")
{
<select class="form-select form-select-sm"
@bind="_matchValueText"
@bind:after="OnMatchValueChanged">
<option value="True">True</option>
<option value="False">False</option>
</select>
}
else if (IsNumericType(t ?? ""))
{
<input type="number" step="any" class="form-control form-control-sm"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
}
else
{
<input type="text" class="form-control form-control-sm"
placeholder="value"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
}
}
</div>
</div>
};
// ── RangeViolation ─────────────────────────────────────────────────────
private RenderFragment RenderRangeViolation() => __builder =>
{
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Minimum
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_minText"
@bind:event="oninput"
@bind:after="OnMinChanged" />
</div>
<div class="col-md-2 text-center pb-1 text-muted small">to</div>
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Maximum
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_maxText"
@bind:event="oninput"
@bind:after="OnMaxChanged" />
</div>
</div>
<div class="mt-3" aria-hidden="true">
<svg viewBox="0 0 200 12" preserveAspectRatio="none"
style="width:100%; height:10px; border-radius:5px; overflow:hidden;">
<rect x="0" y="0" width="20" height="12" fill="#f8d7da" />
<rect x="20" y="0" width="160" height="12" fill="#d1e7dd" />
<rect x="180" y="0" width="20" height="12" fill="#f8d7da" />
</svg>
<div class="d-flex justify-content-between small text-muted mt-1">
<span>alarm</span>
<span>normal</span>
<span>alarm</span>
</div>
</div>
};
private async Task OnMinChanged()
{
_model.Min = ParseDouble(_minText);
await Emit();
}
private async Task OnMaxChanged()
{
_model.Max = ParseDouble(_maxText);
await Emit();
}
// ── RateOfChange ───────────────────────────────────────────────────────
private RenderFragment RenderRateOfChange() => __builder =>
{
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Rate threshold
</label>
<div class="input-group input-group-sm">
<input type="number" step="any" class="form-control"
@bind="_thresholdText"
@bind:event="oninput"
@bind:after="OnThresholdChanged" />
<span class="input-group-text">units / sec</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Sampling window
</label>
<div class="input-group input-group-sm">
<input type="number" step="any" min="0" class="form-control"
@bind="_windowText"
@bind:event="oninput"
@bind:after="OnWindowChanged" />
<span class="input-group-text">sec</span>
</div>
</div>
</div>
<div class="mt-3 row g-2">
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Direction
</label>
<select class="form-select form-select-sm"
@bind="_directionText"
@bind:after="OnDirectionChanged">
<option value="rising">Rising only</option>
<option value="falling">Falling only</option>
<option value="either">Either direction</option>
</select>
</div>
</div>
};
private async Task OnThresholdChanged()
{
_model.ThresholdPerSecond = ParseDouble(_thresholdText);
await Emit();
}
private async Task OnWindowChanged()
{
_model.WindowSeconds = ParseDouble(_windowText);
await Emit();
}
private async Task OnDirectionChanged()
{
_model.Direction = _directionText;
await Emit();
}
private string _directionText = "either";
// ── HiLo ───────────────────────────────────────────────────────────────
private RenderFragment RenderHiLo() => __builder =>
{
<div class="small text-muted mb-2 fst-italic">
Set any subset of the four setpoints. The most-severe matching band
wins (LoLo/HiHi outrank Lo/Hi). Per-setpoint priority overrides the
alarm-level priority for that band. Deadband (optional) relaxes the
deactivation threshold by the configured amount to prevent flapping.
</div>
@HiLoSetpointRow("HIGH-HIGH (critical)",
_hiHiText, v => _hiHiText = v, OnHiHiChanged,
_hiHiDeadbandText, v => _hiHiDeadbandText = v, OnHiHiDeadbandChanged,
_hiHiPriorityText, v => _hiHiPriorityText = v, OnHiHiPriorityChanged,
_hiHiMessageText, v => _hiHiMessageText = v, OnHiHiMessageChanged,
"text-danger")
@HiLoSetpointRow("HIGH (warning)",
_hiText, v => _hiText = v, OnHiChanged,
_hiDeadbandText, v => _hiDeadbandText = v, OnHiDeadbandChanged,
_hiPriorityText, v => _hiPriorityText = v, OnHiPriorityChanged,
_hiMessageText, v => _hiMessageText = v, OnHiMessageChanged,
"text-warning-emphasis")
@HiLoSetpointRow("LOW (warning)",
_loText, v => _loText = v, OnLoChanged,
_loDeadbandText, v => _loDeadbandText = v, OnLoDeadbandChanged,
_loPriorityText, v => _loPriorityText = v, OnLoPriorityChanged,
_loMessageText, v => _loMessageText = v, OnLoMessageChanged,
"text-warning-emphasis")
@HiLoSetpointRow("LOW-LOW (critical)",
_loLoText, v => _loLoText = v, OnLoLoChanged,
_loLoDeadbandText, v => _loLoDeadbandText = v, OnLoLoDeadbandChanged,
_loLoPriorityText, v => _loLoPriorityText = v, OnLoLoPriorityChanged,
_loLoMessageText, v => _loLoMessageText = v, OnLoLoMessageChanged,
"text-danger")
};
/// <summary>
/// Renders one setpoint row: value (number) + priority (int). Both are
/// optional — leaving a value blank disables that band. The
/// <paramref name="severityClass"/> tints the label to convey relative
/// severity at a glance.
/// </summary>
private RenderFragment HiLoSetpointRow(
string label,
string? value, Action<string?> valueSetter, Func<Task> onValueChanged,
string? deadband, Action<string?> deadbandSetter, Func<Task> onDeadbandChanged,
string? priority, Action<string?> prioritySetter, Func<Task> onPriorityChanged,
string? message, Action<string?> messageSetter, Func<Task> onMessageChanged,
string severityClass) => __builder =>
{
<div class="row g-2 align-items-end mb-1">
<div class="col-md-5">
<label class="form-label small text-uppercase fw-semibold mb-1 @severityClass">
@label
</label>
<div class="input-group input-group-sm">
<span class="input-group-text">setpoint</span>
<input type="number" step="any" class="form-control"
placeholder="—"
value="@value"
@oninput="@(e => { valueSetter(e.Value?.ToString()); _ = onValueChanged(); })" />
</div>
</div>
<div class="col-md-3">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Deadband
</label>
<div class="input-group input-group-sm">
<span class="input-group-text">±</span>
<input type="number" step="any" min="0" class="form-control"
placeholder="0"
value="@deadband"
@oninput="@(e => { deadbandSetter(e.Value?.ToString()); _ = onDeadbandChanged(); })" />
</div>
</div>
<div class="col-md-4">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Priority
</label>
<div class="input-group input-group-sm">
<input type="number" min="0" max="1000" class="form-control"
placeholder="@_priority"
value="@priority"
@oninput="@(e => { prioritySetter(e.Value?.ToString()); _ = onPriorityChanged(); })" />
</div>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-12">
<input type="text" class="form-control form-control-sm"
placeholder="Optional operator message for this band…"
value="@message"
@oninput="@(e => { messageSetter(e.Value?.ToString()); _ = onMessageChanged(); })" />
</div>
</div>
};
// Setpoint text mirrors — separate from the model so blank fields stay
// blank (rather than appearing as 0) and we can detect "unset" cleanly.
private string? _loLoText;
private string? _loText;
private string? _hiText;
private string? _hiHiText;
private string? _loLoPriorityText;
private string? _loPriorityText;
private string? _hiPriorityText;
private string? _hiHiPriorityText;
private string? _loLoDeadbandText;
private string? _loDeadbandText;
private string? _hiDeadbandText;
private string? _hiHiDeadbandText;
private string? _loLoMessageText;
private string? _loMessageText;
private string? _hiMessageText;
private string? _hiHiMessageText;
// Mirrored on the parent so the placeholder shows the right fallback.
[Parameter] public int FallbackPriority { get; set; } = 500;
private int _priority => FallbackPriority;
private async Task OnLoLoChanged() { _model.LoLo = ParseDouble(_loLoText); await Emit(); }
private async Task OnLoChanged() { _model.Lo = ParseDouble(_loText); await Emit(); }
private async Task OnHiChanged() { _model.Hi = ParseDouble(_hiText); await Emit(); }
private async Task OnHiHiChanged() { _model.HiHi = ParseDouble(_hiHiText); await Emit(); }
private async Task OnLoLoPriorityChanged() { _model.LoLoPriority = ParseInt(_loLoPriorityText); await Emit(); }
private async Task OnLoPriorityChanged() { _model.LoPriority = ParseInt(_loPriorityText); await Emit(); }
private async Task OnHiPriorityChanged() { _model.HiPriority = ParseInt(_hiPriorityText); await Emit(); }
private async Task OnHiHiPriorityChanged() { _model.HiHiPriority = ParseInt(_hiHiPriorityText); await Emit(); }
private async Task OnLoLoDeadbandChanged() { _model.LoLoDeadband = ParseDouble(_loLoDeadbandText); await Emit(); }
private async Task OnLoDeadbandChanged() { _model.LoDeadband = ParseDouble(_loDeadbandText); await Emit(); }
private async Task OnHiDeadbandChanged() { _model.HiDeadband = ParseDouble(_hiDeadbandText); await Emit(); }
private async Task OnHiHiDeadbandChanged() { _model.HiHiDeadband = ParseDouble(_hiHiDeadbandText); await Emit(); }
private async Task OnLoLoMessageChanged() { _model.LoLoMessage = _loLoMessageText; await Emit(); }
private async Task OnLoMessageChanged() { _model.LoMessage = _loMessageText; await Emit(); }
private async Task OnHiMessageChanged() { _model.HiMessage = _hiMessageText; await Emit(); }
private async Task OnHiHiMessageChanged() { _model.HiHiMessage = _hiHiMessageText; await Emit(); }
private static int? ParseInt(string? s) =>
int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
// ── Text mirrors for typed inputs ──────────────────────────────────────
// @bind requires a settable backing field that round-trips text. We keep
// these in sync with the model and re-parse on @bind:after.
private string? _minText;
private string? _maxText;
private string? _thresholdText;
private string? _windowText;
protected override void OnInitialized()
{
SyncTextMirrors();
}
private void SyncTextMirrors()
{
_attributeName = _model.AttributeName ?? string.Empty;
_matchValueText = _model.MatchValue ?? string.Empty;
_operatorText = _model.NotEquals ? "ne" : "eq";
_minText = FormatNullable(_model.Min);
_maxText = FormatNullable(_model.Max);
_thresholdText = FormatNullable(_model.ThresholdPerSecond);
_windowText = FormatNullable(_model.WindowSeconds);
_directionText = _model.Direction;
_loLoText = FormatNullable(_model.LoLo);
_loText = FormatNullable(_model.Lo);
_hiText = FormatNullable(_model.Hi);
_hiHiText = FormatNullable(_model.HiHi);
_loLoPriorityText = _model.LoLoPriority?.ToString(CultureInfo.InvariantCulture);
_loPriorityText = _model.LoPriority?.ToString(CultureInfo.InvariantCulture);
_hiPriorityText = _model.HiPriority?.ToString(CultureInfo.InvariantCulture);
_hiHiPriorityText = _model.HiHiPriority?.ToString(CultureInfo.InvariantCulture);
_loLoDeadbandText = FormatNullable(_model.LoLoDeadband);
_loDeadbandText = FormatNullable(_model.LoDeadband);
_hiDeadbandText = FormatNullable(_model.HiDeadband);
_hiHiDeadbandText = FormatNullable(_model.HiHiDeadband);
_loLoMessageText = _model.LoLoMessage ?? string.Empty;
_loMessageText = _model.LoMessage ?? string.Empty;
_hiMessageText = _model.HiMessage ?? string.Empty;
_hiHiMessageText = _model.HiHiMessage ?? string.Empty;
}
private string _operatorText = "eq";
private string _matchValueText = string.Empty;
private async Task OnOperatorChanged()
{
_model.NotEquals = (_operatorText == "ne");
await Emit();
}
private async Task OnMatchValueChanged()
{
_model.MatchValue = _matchValueText;
await Emit();
}
// ── Expression ─────────────────────────────────────────────────────────
private RenderFragment RenderExpression() => __builder =>
{
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Trigger expression</label>
<MonacoEditor Height="120px"
Language="csharp"
ScriptKind="ScriptAnalysis.ScriptKind.Template"
ShowToolbar="false"
Value="@(_model.Expression ?? string.Empty)"
ValueChanged="OnExpressionChanged"
SelfAttributes="@TriggerAttributeMapper.SelfAttributes(AvailableAttributes)"
Children="@TriggerAttributeMapper.Children(AvailableAttributes)" />
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
};
private async Task OnExpressionChanged(string value)
{
_model.Expression = value;
await Emit();
}
// ── Hint text ──────────────────────────────────────────────────────────
private string BuildHint()
{
var attr = string.IsNullOrWhiteSpace(_model.AttributeName)
? "the selected attribute"
: $"\"{_model.AttributeName}\"";
return TriggerType switch
{
AlarmTriggerType.ValueMatch =>
$"Triggers when {attr} {(_model.NotEquals ? "is not equal to" : "equals")} \"{_model.MatchValue ?? ""}\".",
AlarmTriggerType.RangeViolation =>
_model.Min.HasValue && _model.Max.HasValue
? $"Triggers when {attr} < {Fmt(_model.Min)} or > {Fmt(_model.Max)}."
: $"Triggers when {attr} goes outside the configured range.",
AlarmTriggerType.RateOfChange =>
$"Triggers when {attr} changes faster than {Fmt(_model.ThresholdPerSecond) ?? "?"} units/sec ({_model.Direction}) over a {Fmt(_model.WindowSeconds) ?? "?"} sec window.",
AlarmTriggerType.HiLo => BuildHiLoHint(attr),
AlarmTriggerType.Expression =>
"Alarm is active while this expression is true.",
_ => string.Empty
};
}
private string BuildHiLoHint(string attr)
{
var parts = new List<string>();
if (_model.LoLo.HasValue) parts.Add($"LoLo at {Fmt(_model.LoLo)}");
if (_model.Lo.HasValue) parts.Add($"Lo at {Fmt(_model.Lo)}");
if (_model.Hi.HasValue) parts.Add($"Hi at {Fmt(_model.Hi)}");
if (_model.HiHi.HasValue) parts.Add($"HiHi at {Fmt(_model.HiHi)}");
if (parts.Count == 0)
return $"Triggers when {attr} crosses any configured setpoint (none set yet).";
return $"Triggers on {attr}: {string.Join(", ", parts)}.";
}
private static string Fmt(double? v) =>
v.HasValue ? v.Value.ToString("0.###", CultureInfo.InvariantCulture) : "";
private static string FormatNullable(double? v) =>
v.HasValue ? v.Value.ToString("R", CultureInfo.InvariantCulture) : "";
private static double? ParseDouble(string? s) =>
double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
}
@@ -0,0 +1,68 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Compose '@SourceName' into…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small text-muted mb-1">Parent template</label>
<select class="form-select form-select-sm" @bind="_parentTemplateId">
<option value="0" disabled selected>Select a parent template…</option>
@foreach (var opt in ParentOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
</div>
<div class="mb-1">
<label class="form-label small text-muted mb-1">Slot name</label>
<input type="text" class="form-control form-control-sm"
placeholder="Slot name"
@bind="_slotName" />
</div>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-2">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit" disabled="@(_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName))">Compose</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int SourceTemplateId { get; set; }
[Parameter] public string SourceName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int SourceTemplateId, int ParentTemplateId, string SlotName)> OnSubmit { get; set; }
private bool _wasVisible;
private int _parentTemplateId;
private string _slotName = string.Empty;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_parentTemplateId = 0;
_slotName = SourceName;
}
_wasVisible = IsVisible;
}
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
private async Task Submit()
{
if (_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName)) return;
await OnSubmit.InvokeAsync((SourceTemplateId, _parentTemplateId, _slotName.Trim()));
}
}
@@ -0,0 +1,156 @@
@* Reusable data table with sorting, filtering, and pagination *@
@typeparam TItem
<div class="mb-2">
@if (ShowSearch)
{
<div class="row mb-2">
<div class="col-md-4">
<div class="input-group input-group-sm">
<input type="text" class="form-control form-control-sm" placeholder="Search..."
@bind="_searchTerm" @bind:event="oninput" @bind:after="ApplyFilter" />
@if (!string.IsNullOrEmpty(_searchTerm))
{
<button type="button" class="btn btn-outline-secondary"
aria-label="Clear search" @onclick="ClearSearch">&times;</button>
}
</div>
</div>
@if (FilterContent != null)
{
<div class="col-md-8 d-flex gap-2 align-items-center">
@FilterContent
</div>
}
</div>
}
</div>
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
@HeaderContent
</tr>
</thead>
<tbody>
@if (_pagedItems.Count == 0)
{
<tr>
<td colspan="100" class="text-muted text-center">@EmptyMessage</td>
</tr>
}
else
{
@foreach (var item in _pagedItems)
{
@RowContent(item)
}
}
</tbody>
</table>
@if (_totalPages > 1)
{
<nav>
<ul class="pagination pagination-sm justify-content-end">
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
<button class="page-link" type="button"
disabled="@(_currentPage <= 1)"
aria-disabled="@((_currentPage <= 1).ToString().ToLowerInvariant())"
@onclick="() => GoToPage(_currentPage - 1)">Previous</button>
</li>
@foreach (var page in PagerWindow.Build(_currentPage, _totalPages))
{
if (page == 0)
{
<li class="page-item disabled">
<span class="page-link">&hellip;</span>
</li>
}
else
{
var p = page;
<li class="page-item @(p == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(p)">@(p)</button>
</li>
}
}
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
<button class="page-link" type="button"
disabled="@(_currentPage >= _totalPages)"
aria-disabled="@((_currentPage >= _totalPages).ToString().ToLowerInvariant())"
@onclick="() => GoToPage(_currentPage + 1)">Next</button>
</li>
</ul>
</nav>
}
<div class="text-muted small">
Showing @((_currentPage - 1) * PageSize + 1)&ndash;@Math.Min(_currentPage * PageSize, _filteredItems.Count) of @_filteredItems.Count items
</div>
@code {
private string _searchTerm = string.Empty;
private int _currentPage = 1;
private List<TItem> _filteredItems = new();
private List<TItem> _pagedItems = new();
private int _totalPages;
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public RenderFragment HeaderContent { get; set; } = default!;
[Parameter, EditorRequired] public RenderFragment<TItem> RowContent { get; set; } = default!;
[Parameter] public RenderFragment? FilterContent { get; set; }
[Parameter] public int PageSize { get; set; } = 25;
[Parameter] public bool ShowSearch { get; set; } = true;
[Parameter] public string EmptyMessage { get; set; } = "No items found.";
[Parameter] public Func<TItem, string, bool>? SearchFilter { get; set; }
protected override void OnParametersSet()
{
ApplyFilter();
}
private void ApplyFilter()
{
if (!string.IsNullOrWhiteSpace(_searchTerm) && SearchFilter != null)
{
_filteredItems = Items.Where(i => SearchFilter(i, _searchTerm)).ToList();
}
else
{
_filteredItems = Items.ToList();
}
_totalPages = Math.Max(1, (int)Math.Ceiling(_filteredItems.Count / (double)PageSize));
if (_currentPage > _totalPages) _currentPage = 1;
UpdatePage();
}
private void GoToPage(int page)
{
if (page < 1 || page > _totalPages) return;
_currentPage = page;
UpdatePage();
}
private void ClearSearch()
{
_searchTerm = string.Empty;
ApplyFilter();
}
private void UpdatePage()
{
_pagedItems = _filteredItems
.Skip((_currentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
}
public void Refresh()
{
ApplyFilter();
StateHasChanged();
}
}
@@ -0,0 +1,154 @@
@* Single global host component for IDialogService. Mounted once in MainLayout.
Listens to DialogService.OnChange and renders the current dialog state.
z-index ladder follows the same convention as ConfirmDialog/DiffDialog:
Toast container 1090 > this modal 1055 > this backdrop 1040. *@
@implements IDisposable
@inject IDialogService Service
@inject IJSRuntime JS
@if (Service is DialogService svc && svc.Current is { } state)
{
<div class="modal-backdrop fade show"></div>
<div @ref="_modalRef"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
aria-modal="true"
@onkeydown="OnKeyDown">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@state.Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
@if (state.Kind == DialogKind.Confirm)
{
<p class="mb-0">@state.Body</p>
}
else
{
<label class="form-label">@state.Body</label>
<input @ref="_promptInputRef"
class="form-control form-control-sm"
placeholder="@state.Placeholder"
value="@_promptValue"
@oninput="OnPromptInput" />
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Cancel">Cancel</button>
<button type="button"
class="btn @(state.Danger ? "btn-danger" : "btn-primary") btn-sm"
@onclick="Confirm">
@ConfirmLabel(state)
</button>
</div>
</div>
</div>
</div>
}
@code {
private ElementReference _modalRef;
private ElementReference _promptInputRef;
private string _promptValue = string.Empty;
private DialogState? _lastSeenState;
private DialogState? _focusedForState;
protected override void OnInitialized()
{
// OnChange lives on the concrete DialogService — the interface stays
// narrow (just ConfirmAsync / PromptAsync). DI hands us the concrete
// instance, so a cast here is safe.
if (Service is DialogService svc) svc.OnChange += OnServiceChanged;
}
public void Dispose()
{
if (Service is DialogService svc) svc.OnChange -= OnServiceChanged;
}
private void OnServiceChanged()
{
// Seed prompt input value when a new prompt dialog opens.
if (Service is DialogService s && s.Current is { Kind: DialogKind.Prompt } promptState
&& !ReferenceEquals(promptState, _lastSeenState))
{
_promptValue = promptState.PromptInitial;
}
_lastSeenState = (Service as DialogService)?.Current;
InvokeAsync(StateHasChanged);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
var current = (Service as DialogService)?.Current;
if (current is not null)
{
try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); }
catch { /* prerender: no JS — ignore */ }
// Focus once per opened dialog. Without this guard, every input
// keystroke triggers a re-render which would re-focus the modal
// element and yank the caret off the prompt input.
if (!ReferenceEquals(current, _focusedForState))
{
_focusedForState = current;
try
{
if (current.Kind == DialogKind.Prompt)
await _promptInputRef.FocusAsync();
else
await _modalRef.FocusAsync();
}
catch { /* element not yet attached: ignore */ }
}
}
else
{
_focusedForState = null;
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
catch { /* prerender: no JS — ignore */ }
}
}
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
Cancel();
}
}
private void OnPromptInput(ChangeEventArgs e)
{
_promptValue = e.Value?.ToString() ?? string.Empty;
}
private void Cancel()
{
if (Service is not DialogService svc || svc.Current is null) return;
var resolveValue = svc.Current.Kind == DialogKind.Confirm
? (object)false
: (object?)null;
_promptValue = string.Empty;
svc.Resolve(resolveValue);
}
private void Confirm()
{
if (Service is not DialogService svc || svc.Current is null) return;
var resolveValue = svc.Current.Kind == DialogKind.Confirm
? (object)true
: (object?)_promptValue;
_promptValue = string.Empty;
svc.Resolve(resolveValue);
}
private static string ConfirmLabel(DialogState state) => state.Kind switch
{
DialogKind.Prompt => "Save",
_ => state.Danger ? "Delete" : "Confirm",
};
}
@@ -0,0 +1,115 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Default <see cref="IDialogService"/> implementation. Holds the currently
/// open dialog state in <see cref="Current"/> and notifies subscribers (the
/// <c>DialogHost</c> component) via <see cref="OnChange"/>. Only a single
/// dialog can be open at a time; attempting to open another while one is
/// already active throws <see cref="InvalidOperationException"/> — there is
/// no nested-dialog use case today and surfacing the bug is preferable to
/// silently queuing.
/// </summary>
public class DialogService : IDialogService
{
/// <summary>
/// Raised whenever <see cref="Current"/> changes (dialog opened or closed).
/// The host component subscribes and calls <c>StateHasChanged</c>.
/// </summary>
public event Action? OnChange;
/// <summary>
/// The dialog currently being displayed, or <c>null</c> when no dialog is
/// open. The host reads this to decide what (if anything) to render.
/// </summary>
public DialogState? Current { get; private set; }
// CentralUI-015: the pending dialog result is held in a typed TCS that the
// host completes directly via Resolve(). The previous implementation
// projected the result through Task.ContinueWith(..., TaskScheduler.Default),
// which ran the projection lambda on a thread-pool thread. Completing a
// strongly-typed TCS directly removes that off-render-thread hop entirely —
// the awaiting caller resumes on whatever SynchronizationContext it captured
// (the Blazor renderer's, for an event-handler caller).
private TaskCompletionSource<object?>? _tcs;
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
{
EnsureNoActiveDialog();
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
_tcs = tcs;
Current = new DialogState(title, DialogKind.Confirm, message, danger, PromptInitial: string.Empty, Placeholder: null);
OnChange?.Invoke();
return Project(tcs.Task, static r => r is bool b && b);
}
/// <inheritdoc />
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
{
EnsureNoActiveDialog();
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
_tcs = tcs;
Current = new DialogState(title, DialogKind.Prompt, label, Danger: false, PromptInitial: initialValue, Placeholder: placeholder);
OnChange?.Invoke();
return Project(tcs.Task, static r => r as string);
}
/// <summary>
/// Awaits the host's result and projects it to the caller's type. The
/// <c>await</c> here resumes on the caller's captured context (the renderer
/// sync context for an event-handler caller), not a thread-pool thread.
/// </summary>
private static async Task<TResult> Project<TResult>(Task<object?> source, Func<object?, TResult> selector)
{
var result = await source.ConfigureAwait(false);
return selector(result);
}
/// <summary>
/// Called by the host component when the user dismisses or confirms the
/// dialog. <paramref name="result"/> must be a <c>bool</c> for confirms
/// and a <c>string?</c> for prompts (null = cancel).
/// </summary>
/// <param name="result">The user's response: a <c>bool</c> for confirms or a <c>string?</c> for prompts.</param>
internal void Resolve(object? result)
{
var tcs = _tcs;
_tcs = null;
Current = null;
OnChange?.Invoke();
tcs?.TrySetResult(result);
}
private void EnsureNoActiveDialog()
{
if (Current is not null)
{
throw new InvalidOperationException(
"A dialog is already open. IDialogService does not support nested dialogs.");
}
}
}
/// <summary>
/// Snapshot of a dialog's display state, exposed read-only on
/// <see cref="DialogService.Current"/> for the host component to render.
/// </summary>
/// <param name="Title">Modal title text.</param>
/// <param name="Kind">Discriminates between confirm and prompt rendering.</param>
/// <param name="Body">For confirm: the message; for prompt: the input label.</param>
/// <param name="Danger">When true, the confirm button uses danger styling.</param>
/// <param name="PromptInitial">Initial value for prompt-kind dialogs.</param>
/// <param name="Placeholder">Placeholder shown when the prompt input is empty.</param>
public record DialogState(
string Title,
DialogKind Kind,
string Body,
bool Danger,
string PromptInitial,
string? Placeholder);
public enum DialogKind
{
Confirm,
Prompt
}
@@ -0,0 +1,185 @@
@* Reusable diff/comparison dialog using Bootstrap modal.
Mirrors the ConfirmDialog API: callers invoke ShowAsync(title, before, after)
via @ref to display a side-by-side or simple before/after comparison.
z-index ladder follows ConfirmDialog: modal 1055 > backdrop 1040 (toasts at 1090). *@
@inject IJSRuntime JS
@inject ILogger<DiffDialog> Logger
@implements IAsyncDisposable
@if (_visible)
{
<div class="modal-backdrop fade show"></div>
<div @ref="_modalRef"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
@onkeydown="OnKeyDownAsync">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close diff dialog" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (BodyContent != null)
{
@BodyContent
}
else
{
<div class="row g-3">
<div class="col-md-6">
<div class="small text-muted mb-1">Before</div>
<pre class="border rounded p-2 small bg-light mb-0" style="max-height: 50vh; overflow: auto; white-space: pre-wrap;">@Before</pre>
</div>
<div class="col-md-6">
<div class="small text-muted mb-1">After</div>
<pre class="border rounded p-2 small bg-light mb-0" style="max-height: 50vh; overflow: auto; white-space: pre-wrap;">@After</pre>
</div>
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" @onclick="Close">Close</button>
</div>
</div>
</div>
</div>
}
@code {
private bool _visible;
private bool _bodyLocked;
private TaskCompletionSource<bool>? _tcs;
private ElementReference _modalRef;
[Parameter] public string Title { get; set; } = "Diff";
[Parameter] public string Before { get; set; } = string.Empty;
[Parameter] public string After { get; set; } = string.Empty;
/// <summary>
/// Optional custom body content. When supplied, it replaces the default
/// before/after panes — useful when the caller wants to render a richer
/// comparison (e.g. metadata badges, file lists, etc.).
/// </summary>
[Parameter] public RenderFragment? BodyContent { get; set; }
/// <summary>
/// Show the dialog with the supplied title and before/after text.
/// Returns when the user dismisses the dialog.
/// </summary>
public Task<bool> ShowAsync(string title, string before, string after)
{
Title = title;
Before = before;
After = after;
BodyContent = null;
return OpenAsync();
}
/// <summary>
/// Show the dialog with a custom body. Useful when the diff is not a
/// simple before/after string pair (e.g. a deployment comparison summary).
/// </summary>
public Task<bool> ShowAsync(string title, RenderFragment body)
{
Title = title;
BodyContent = body;
return OpenAsync();
}
private Task<bool> OpenAsync()
{
_visible = true;
_tcs = new TaskCompletionSource<bool>();
StateHasChanged();
return _tcs.Task;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_visible && !_bodyLocked)
{
_bodyLocked = true;
await TryLockBodyAsync();
try { await _modalRef.FocusAsync(); }
catch (InvalidOperationException)
{
// Prerender: the element reference is not attached yet — the
// next interactive render focuses it. Expected, not logged.
}
catch (JSDisconnectedException)
{
// Circuit gone before focus could run — nothing to do.
}
catch (JSException ex)
{
// A genuine focus interop failure (CentralUI-023) — log it.
Logger.LogWarning(ex, "DiffDialog: failed to focus the modal.");
}
}
}
private async Task OnKeyDownAsync(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
Close();
await Task.CompletedTask;
}
}
private void Close()
{
_visible = false;
_ = TryUnlockBodyAsync();
_tcs?.TrySetResult(true);
}
private async Task TryLockBodyAsync()
{
try
{
await JS.InvokeVoidAsync("document.body.classList.add", "modal-open");
}
catch (JSDisconnectedException)
{
// Circuit gone — the body scroll lock is moot. Expected, silent.
}
catch (JSException ex)
{
// CentralUI-023: a genuine interop failure — log instead of doing
// another (also-failing) JS call inside a bare catch.
Logger.LogWarning(ex, "DiffDialog: failed to apply body scroll lock.");
}
}
private async Task TryUnlockBodyAsync()
{
_bodyLocked = false;
try
{
await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open");
}
catch (JSDisconnectedException)
{
// Circuit gone — the body scroll lock is moot. Expected, silent.
}
catch (JSException ex)
{
Logger.LogWarning(ex, "DiffDialog: failed to remove body scroll lock.");
}
}
public async ValueTask DisposeAsync()
{
// CentralUI-011: if the dialog is disposed while still open (the user
// navigated away), complete the pending task so the awaiting caller
// resumes deterministically instead of hanging forever.
_tcs?.TrySetResult(false);
if (_bodyLocked)
{
await TryUnlockBodyAsync();
}
}
}
@@ -0,0 +1,53 @@
using System.Globalization;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Converts a <see cref="TimeSpan"/> to and from the number+unit pair behind a
/// duration input (milliseconds / seconds / minutes). A blank or non-positive
/// number represents "unset" (a <c>null</c> duration).
/// </summary>
internal static class DurationInput
{
/// <summary>The unit tokens a duration input offers, smallest first.</summary>
internal static readonly string[] Units = { "ms", "sec", "min" };
/// <summary>
/// Splits a duration into the largest whole unit that represents it exactly.
/// A null or non-positive duration yields a blank value and the default
/// <c>sec</c> unit.
/// </summary>
/// <param name="duration">The duration to split, or null for unset.</param>
internal static (string? Value, string Unit) Split(TimeSpan? duration)
{
if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec");
var ms = (long)d.TotalMilliseconds;
if (ms % 60000 == 0) return ((ms / 60000).ToString(CultureInfo.InvariantCulture), "min");
if (ms % 1000 == 0) return ((ms / 1000).ToString(CultureInfo.InvariantCulture), "sec");
return (ms.ToString(CultureInfo.InvariantCulture), "ms");
}
/// <summary>
/// Composes a number+unit pair into a duration. A blank, unparseable, or
/// non-positive value yields <c>null</c> (unset).
/// </summary>
/// <param name="value">The numeric string entered by the user.</param>
/// <param name="unit">The selected unit token (ms, sec, or min).</param>
internal static TimeSpan? Compose(string? value, string unit)
{
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|| n <= 0)
{
return null;
}
var factorMs = unit switch
{
"min" => 60000L,
"ms" => 1L,
_ => 1000L,
};
return TimeSpan.FromMilliseconds(n * factorMs);
}
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Centralised dialog/modal service. Pages inject this service and call
/// <see cref="ConfirmAsync"/> or <see cref="PromptAsync"/> programmatically
/// instead of embedding per-page modal components. A single <c>DialogHost</c>
/// rendered in <c>MainLayout</c> displays the resulting dialog state.
/// </summary>
public interface IDialogService
{
/// <summary>
/// Shows a confirmation dialog and resolves to <c>true</c> when the user
/// confirms, or <c>false</c> when the user cancels (button click, Escape,
/// or backdrop dismiss).
/// </summary>
/// <param name="title">Modal title text.</param>
/// <param name="message">Body text shown to the user.</param>
/// <param name="danger">When <c>true</c>, the confirm button renders in
/// <c>btn-danger</c> styling with the label "Delete"; otherwise a primary
/// "Confirm" button is shown.</param>
Task<bool> ConfirmAsync(string title, string message, bool danger = false);
/// <summary>
/// Shows a single-line text prompt and resolves to the entered value, or
/// <c>null</c> if the user cancels.
/// </summary>
/// <param name="title">Modal title text.</param>
/// <param name="label">Label rendered above the input field.</param>
/// <param name="initialValue">Pre-populated value for the input field.</param>
/// <param name="placeholder">Optional placeholder shown when the input is empty.</param>
Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null);
}
@@ -0,0 +1,17 @@
@* Reusable loading spinner *@
@if (IsLoading)
{
<div class="d-flex align-items-center text-secondary @CssClass">
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<span>@Message</span>
</div>
}
@code {
[Parameter] public bool IsLoading { get; set; }
[Parameter] public string Message { get; set; } = "Loading...";
[Parameter] public string CssClass { get; set; } = "";
}
@@ -0,0 +1,235 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
@if (ShowToolbar)
{
<div class="d-flex justify-content-end align-items-center gap-3 mb-1 small text-muted">
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="FormatAsync"
title="Format document (Ctrl/Cmd+Shift+F)">Format</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleWrap"
title="Word wrap">@(_wrap ? "Wrap on" : "Wrap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleMinimap"
title="Toggle minimap">@(_minimap ? "Minimap on" : "Minimap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleTheme"
title="Toggle theme">@(_dark ? "Dark" : "Light")</button>
</div>
}
<div @ref="_hostRef" class="monaco-editor-host"
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public string Language { get; set; } = "csharp";
[Parameter] public string Height { get; set; } = "320px";
[Parameter] public bool ReadOnly { get; set; } = false;
[Parameter] public bool ShowToolbar { get; set; } = true;
/// <summary>
/// Runtime globals surface the script is analyzed against. Defaults to
/// template/shared-script globals; set to <c>InboundApi</c> on the API
/// method editor so <c>Route</c> and <c>Parameters</c> type-check.
/// </summary>
[Parameter] public ScriptAnalysis.ScriptKind ScriptKind { get; set; } = ScriptAnalysis.ScriptKind.Template;
/// <summary>
/// Parameter names declared on the form (derived from the SchemaBuilder's
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
/// and used by the unknown-key diagnostic.
/// </summary>
[Parameter] public IReadOnlyList<string>? DeclaredParameters { get; set; }
/// <summary>
/// Full shapes (name + type + required) for the declared parameters.
/// Used by Parameters["name"] hover to show the declared type. If null,
/// derived from <see cref="DeclaredParameters"/> with type "Object".
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.ParameterShape>? DeclaredParameterShapes { get; set; }
/// <summary>
/// Shapes (name + parameter list + return type) of other scripts on the
/// same template. Surfaced inside CallScript("...") for completion,
/// signature help, hover, and argument-count diagnostics.
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.ScriptShape>? SiblingScripts { get; set; }
/// <summary>
/// Attributes declared on the current template. Surfaced inside
/// <c>Attributes["..."]</c> for completion and SCADA006 diagnostics.
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.AttributeShape>? SelfAttributes { get; set; }
/// <summary>
/// Child compositions on the current template, each with its template's
/// attributes and scripts. Surfaced for <c>Children["X"].Attributes</c>,
/// <c>Children["X"].CallScript</c>, and SCADA007 diagnostics.
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.CompositionContext>? Children { get; set; }
/// <summary>
/// Parent template when the current template is composed inside exactly
/// one other template. <c>null</c> at the root or when multiple parents
/// exist. Surfaced for <c>Parent.Attributes</c> / <c>Parent.CallScript</c>.
/// </summary>
[Parameter] public ScriptAnalysis.CompositionContext? Parent { get; set; }
/// <summary>
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
/// debounce). Hosts can render a <see cref="ProblemsPanel"/> with the same
/// data.
/// </summary>
[Parameter] public EventCallback<IReadOnlyList<ScriptAnalysis.DiagnosticMarker>> MarkersChanged { get; set; }
private ElementReference _hostRef;
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
private readonly string _id = Guid.NewGuid().ToString("N");
private string _lastSentValue = "";
private bool _initialized;
private bool _wrap;
private bool _minimap;
private bool _dark;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_dotNetRef = DotNetObjectReference.Create(this);
_lastSentValue = Value ?? "";
try
{
await JS.InvokeVoidAsync(
"MonacoBlazor.createEditor",
_id,
_hostRef,
new
{
value = Value ?? "",
language = Language,
readOnly = ReadOnly
},
_dotNetRef);
_initialized = true;
}
catch (InvalidOperationException)
{
// Prerendering: JS interop is not available yet — the next
// (interactive) render retries. Expected, not logged.
}
catch (JSDisconnectedException)
{
// Circuit disconnected before init completed — nothing to do.
}
catch (JSException ex)
{
// A genuine Monaco init failure — surface it instead of hiding it.
Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id);
}
}
else if (_initialized && (Value ?? "") != _lastSentValue)
{
_lastSentValue = Value ?? "";
await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue);
}
}
/// <summary>
/// Invokes a Monaco JS function, swallowing the expected disconnect case but
/// logging any genuine JS error (CentralUI-018) so failures are not silent.
/// </summary>
private async ValueTask SafeInvokeAsync(string fn, string action, params object?[] args)
{
try
{
await JS.InvokeVoidAsync(fn, args);
}
catch (JSDisconnectedException)
{
// Circuit gone — the editor no longer exists; nothing to log.
}
catch (JSException ex)
{
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to {Action}.", _id, action);
}
}
[JSInvokable]
public Task OnValueChanged(string newValue)
{
_lastSentValue = newValue ?? "";
return ValueChanged.InvokeAsync(_lastSentValue);
}
[JSInvokable]
public Task OnMarkersChanged(ScriptAnalysis.DiagnosticMarker[] markers) =>
MarkersChanged.InvokeAsync(markers ?? Array.Empty<ScriptAnalysis.DiagnosticMarker>());
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
public async Task RevealLineAsync(int line, int column = 1)
{
if (!_initialized) return;
await SafeInvokeAsync("MonacoBlazor.revealLine", "reveal line", _id, line, column);
}
/// <summary>
/// Called from JS at completion-request time so the form's latest state is
/// passed through, not whatever was captured when the editor was created.
/// </summary>
[JSInvokable]
public ScadaContext GetContext() => new(
DeclaredParameters?.ToArray() ?? Array.Empty<string>(),
SiblingScripts?.ToArray() ?? Array.Empty<ScriptAnalysis.ScriptShape>(),
DeclaredParameterShapes?.ToArray()
?? DeclaredParameters?.Select(n => new ScriptAnalysis.ParameterShape(n, "Object", true)).ToArray()
?? Array.Empty<ScriptAnalysis.ParameterShape>(),
SelfAttributes?.ToArray() ?? Array.Empty<ScriptAnalysis.AttributeShape>(),
Children?.ToArray() ?? Array.Empty<ScriptAnalysis.CompositionContext>(),
Parent,
ScriptKind);
private async Task FormatAsync()
{
if (!_initialized) return;
await SafeInvokeAsync("MonacoBlazor.format", "format document", _id);
}
private async Task ToggleWrap()
{
_wrap = !_wrap;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle word wrap", _id, "wordWrap", _wrap ? "on" : "off");
}
private async Task ToggleMinimap()
{
_minimap = !_minimap;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap });
}
private async Task ToggleTheme()
{
_dark = !_dark;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle theme", _id, "theme", _dark ? "vs-dark" : "vs");
}
public async ValueTask DisposeAsync()
{
if (_initialized)
{
// Disposal commonly races a circuit disconnect — JSDisconnectedException
// here is expected and silent; a real JSException is still logged.
await SafeInvokeAsync("MonacoBlazor.dispose", "dispose editor", _id);
}
_dotNetRef?.Dispose();
}
public record ScadaContext(
string[] DeclaredParameters,
ScriptAnalysis.ScriptShape[] SiblingScripts,
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes,
ScriptAnalysis.AttributeShape[] SelfAttributes,
ScriptAnalysis.CompositionContext[] Children,
ScriptAnalysis.CompositionContext? Parent,
ScriptAnalysis.ScriptKind ScriptKind);
}
@@ -0,0 +1,58 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move '@FolderName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_targetParentId">
@foreach (var opt in FolderOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int FolderId { get; set; }
[Parameter] public string FolderName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int FolderId, int? NewParentId)> OnSubmit { get; set; }
private bool _wasVisible;
private int? _targetParentId;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_targetParentId = null;
}
_wasVisible = IsVisible;
}
private async Task Close()
{
await IsVisibleChanged.InvokeAsync(false);
}
private async Task Submit()
{
await OnSubmit.InvokeAsync((FolderId, _targetParentId));
}
}
@@ -0,0 +1,59 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move '@TemplateName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_targetFolderId">
@foreach (var opt in FolderOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int TemplateId { get; set; }
[Parameter] public string TemplateName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int TemplateId, int? NewFolderId)> OnSubmit { get; set; }
private bool _wasVisible;
private int? _targetFolderId;
protected override void OnParametersSet()
{
// Reset internal state on transition from hidden -> visible.
if (IsVisible && !_wasVisible)
{
_targetFolderId = null;
}
_wasVisible = IsVisible;
}
private async Task Close()
{
await IsVisibleChanged.InvokeAsync(false);
}
private async Task Submit()
{
await OnSubmit.InvokeAsync((TemplateId, _targetFolderId));
}
}
@@ -0,0 +1,40 @@
@typeparam TValue
@*
Compact multi-select control: a Bootstrap dropdown whose toggle button
summarises the current selection over a checkbox menu. Replaces a wrapped
block of chip buttons with a single control of one row's height.
*@
<div class="dropdown msd" data-test="@DataTest">
<button type="button"
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
disabled="@(Items.Count == 0)"
data-test="@($"{DataTest}-toggle")">
<span class="msd-summary">@Summary()</span>
</button>
<ul class="dropdown-menu msd-menu">
@if (Items.Count == 0)
{
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
}
else
{
@foreach (var item in Items)
{
var isSelected = Selected.Contains(item);
<li>
<label class="dropdown-item msd-item">
<input type="checkbox"
class="form-check-input msd-check"
checked="@isSelected"
@onchange="() => Toggle(item)"
data-test="@($"{DataTest}-opt-{item}")" />
<span>@Display(item)</span>
</label>
</li>
}
}
</ul>
</div>
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Components;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
/// summarises the current selection ("All" when empty, the single item's label
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
///
/// <para>
/// It exists to keep multi-value filter controls one row tall instead of a
/// wrapped block of chip buttons. The component mutates the caller-owned
/// <see cref="Selected"/> collection in place and raises
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
/// (re-render, prune dependent selections, …).
/// </para>
///
/// <para>
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
/// while the operator ticks several boxes.
/// </para>
/// </summary>
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
public partial class MultiSelectDropdown<TValue> where TValue : notnull
{
/// <summary>The options shown in the menu, in display order.</summary>
[Parameter, EditorRequired]
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
/// <summary>
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
/// </summary>
[Parameter, EditorRequired]
public ICollection<TValue> Selected { get; set; } = default!;
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
[Parameter]
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
[Parameter]
public EventCallback SelectionChanged { get; set; }
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
[Parameter]
public string AllLabel { get; set; } = "All";
/// <summary>Text shown in the menu when there are no options.</summary>
[Parameter]
public string EmptyText { get; set; } = "None available";
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
[Parameter]
public string DataTest { get; set; } = "multi-select";
private async Task Toggle(TValue item)
{
// ICollection.Remove returns false when the item was absent — that is the
// "not currently selected" case, so add it. This is a plain toggle.
if (!Selected.Remove(item))
{
Selected.Add(item);
}
await SelectionChanged.InvokeAsync();
}
private string Summary()
{
var count = Selected.Count;
if (count == 0)
{
return AllLabel;
}
if (count == 1)
{
// Prefer the single selection's label over a bare "1 selected".
foreach (var item in Items)
{
if (Selected.Contains(item))
{
return Display(item);
}
}
// The one selected value is not in the current Items list (e.g. a Kind
// narrowed out by a Channel change before the parent pruned it).
return "1 selected";
}
return $"{count} selected";
}
}
@@ -0,0 +1,32 @@
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
form-control-sm controls in a filter row. */
.msd-toggle {
min-width: 9rem;
max-width: 15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Keep a long option list from running off-screen — scroll within the menu. */
.msd-menu {
max-height: 16rem;
overflow-y: auto;
}
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
menu stays open thanks to data-bs-auto-close="outside". */
.msd-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Neutralise the default form-check-input top margin so the box lines up with
the option text inside the dropdown-item. */
.msd-check {
flex: 0 0 auto;
margin: 0;
}
@@ -0,0 +1,14 @@
<div class="d-flex align-items-center justify-content-center min-vh-100">
<div class="card shadow-sm" style="max-width: 480px; width: 100%;">
<div class="card-body p-4">
<h4 class="card-title mb-3 text-center">ScadaBridge</h4>
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading">Not Authorized</h5>
<p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p>
</div>
<div class="d-flex justify-content-center">
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
</div>
</div>
</div>
</div>
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Pure helper for windowed pagination (CentralUI-016). Computes the set of
/// page numbers a pager should render: always the first and last page plus a
/// small range around the current page, with the rest elided. Keeps the
/// rendered button count bounded regardless of the total page count, instead
/// of emitting one <c>&lt;li&gt;</c> per page.
/// </summary>
public static class PagerWindow
{
/// <summary>
/// Returns the page numbers to render. A value of <c>0</c> in the result is
/// an ellipsis placeholder (a gap between non-contiguous page numbers).
/// <paramref name="radius"/> is how many pages to show on each side of the
/// current page.
/// </summary>
/// <param name="currentPage">The currently active page (1-based).</param>
/// <param name="totalPages">The total number of pages.</param>
/// <param name="radius">Number of page buttons to show on each side of the current page; default 2.</param>
/// <returns>An ordered list of page numbers and <c>0</c> ellipsis placeholders.</returns>
public static IReadOnlyList<int> Build(int currentPage, int totalPages, int radius = 2)
{
if (totalPages <= 1)
{
return totalPages == 1 ? new[] { 1 } : Array.Empty<int>();
}
currentPage = Math.Clamp(currentPage, 1, totalPages);
// Small enough that windowing buys nothing — render every page.
var maxUnwindowed = 2 * radius + 5;
if (totalPages <= maxUnwindowed)
{
return Enumerable.Range(1, totalPages).ToList();
}
var pages = new SortedSet<int> { 1, totalPages };
for (var p = currentPage - radius; p <= currentPage + radius; p++)
{
if (p >= 1 && p <= totalPages)
{
pages.Add(p);
}
}
// Walk the sorted set, inserting an ellipsis (0) wherever a gap exists.
var result = new List<int>();
var previous = 0;
foreach (var page in pages)
{
if (previous != 0 && page - previous > 1)
{
result.Add(0); // ellipsis
}
result.Add(page);
previous = page;
}
return result;
}
}
@@ -0,0 +1,180 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@using System.Text.Json
@*
Renders an input row per declared parameter so the user can supply values
for a script test run. Primitive types get typed inputs (text / number /
checkbox); Object and List fall back to a JSON textarea with inline parse
errors. The companion SchemaBuilder edits the schema; this edits values.
*@
@if (Shapes.Count == 0)
{
<div class="text-muted small fst-italic">No parameters declared.</div>
}
else
{
<div class="d-flex flex-column gap-2">
@foreach (var shape in Shapes)
{
<div class="row g-2 align-items-center">
<div class="col-sm-4">
<label class="form-label small mb-0" for="@FieldId(shape)">
<code>@shape.Name</code>
<span class="text-muted ms-1">@shape.Type@(shape.Required ? "" : "?")</span>
</label>
</div>
<div class="col-sm-8">
@RenderInput(shape)
@if (_parseErrors.TryGetValue(shape.Name, out var err))
{
<div class="text-danger small mt-1">@err</div>
}
</div>
</div>
}
</div>
}
@code {
[Parameter] public string? ParameterDefinitions { get; set; }
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
private IReadOnlyList<ParameterShape> Shapes =>
ScriptParameterNames.ParseShapes(ParameterDefinitions);
private readonly Dictionary<string, string> _rawText = new();
private readonly Dictionary<string, string> _parseErrors = new();
private static string FieldId(ParameterShape shape) => $"param-{shape.Name}";
private RenderFragment RenderInput(ParameterShape shape) => __builder =>
{
switch (shape.Type)
{
case "Boolean":
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@FieldId(shape)"
checked="@AsBool(shape.Name)"
@onchange="e => SetBool(shape.Name, (bool)(e.Value ?? false))" />
</div>
break;
case "Integer":
<input class="form-control form-control-sm" type="number" step="1" id="@FieldId(shape)"
value="@AsRaw(shape.Name)"
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: true)" />
break;
case "Float":
<input class="form-control form-control-sm" type="number" step="any" id="@FieldId(shape)"
value="@AsRaw(shape.Name)"
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: false)" />
break;
case "String":
<input class="form-control form-control-sm" type="text" id="@FieldId(shape)"
value="@AsRaw(shape.Name)"
@oninput="e => SetString(shape.Name, (string?)e.Value)" />
break;
default: // Object, List, List<...>, unknown
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@FieldId(shape)"
placeholder='@($"JSON {shape.Type.ToLowerInvariant()}")'
@oninput="e => SetJson(shape.Name, (string?)e.Value)">@AsRaw(shape.Name)</textarea>
break;
}
};
private string AsRaw(string name) =>
_rawText.TryGetValue(name, out var raw) ? raw : "";
private bool AsBool(string name) =>
Values.TryGetValue(name, out var v) && v is bool b && b;
private async Task SetString(string name, string? raw)
{
_rawText[name] = raw ?? "";
_parseErrors.Remove(name);
Values[name] = raw ?? "";
await ValuesChanged.InvokeAsync(Values);
}
private async Task SetBool(string name, bool value)
{
_parseErrors.Remove(name);
Values[name] = value;
await ValuesChanged.InvokeAsync(Values);
}
private async Task SetNumeric(string name, string? raw, bool integerOnly)
{
_rawText[name] = raw ?? "";
if (string.IsNullOrWhiteSpace(raw))
{
_parseErrors.Remove(name);
Values.Remove(name);
await ValuesChanged.InvokeAsync(Values);
return;
}
if (integerOnly && long.TryParse(raw, out var i))
{
_parseErrors.Remove(name);
Values[name] = i;
}
else if (!integerOnly && double.TryParse(raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var d))
{
_parseErrors.Remove(name);
Values[name] = d;
}
else
{
_parseErrors[name] = integerOnly ? "Not a valid integer." : "Not a valid number.";
Values.Remove(name);
}
await ValuesChanged.InvokeAsync(Values);
}
private async Task SetJson(string name, string? raw)
{
_rawText[name] = raw ?? "";
if (string.IsNullOrWhiteSpace(raw))
{
_parseErrors.Remove(name);
Values.Remove(name);
await ValuesChanged.InvokeAsync(Values);
return;
}
try
{
using var doc = JsonDocument.Parse(raw);
Values[name] = JsonElementToObject(doc.RootElement.Clone());
_parseErrors.Remove(name);
}
catch (JsonException ex)
{
_parseErrors[name] = $"JSON parse error: {ex.Message}";
Values.Remove(name);
}
await ValuesChanged.InvokeAsync(Values);
}
private static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
_ => null
};
}
}
@@ -0,0 +1,68 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@if (Markers.Count > 0)
{
<div class="card mt-2 mb-3">
<div class="card-header py-1 small d-flex justify-content-between align-items-center">
<span>
@if (_errorCount > 0)
{
<span class="badge bg-danger me-1">@_errorCount error@(_errorCount == 1 ? "" : "s")</span>
}
@if (_warningCount > 0)
{
<span class="badge bg-warning text-dark me-1">@_warningCount warning@(_warningCount == 1 ? "" : "s")</span>
}
@if (_infoCount > 0)
{
<span class="badge bg-info text-dark me-1">@_infoCount info</span>
}
</span>
<span class="text-muted">Problems</span>
</div>
<ul class="list-unstyled mb-0 small" style="max-height: 180px; overflow-y: auto;">
@foreach (var m in Markers)
{
<li class="d-flex gap-2 align-items-start px-2 py-1 border-bottom">
<span class="badge @SeverityBadge(m.Severity)" style="min-width: 60px;">@SeverityLabel(m.Severity)</span>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none flex-grow-1 text-start"
@onclick="@(() => OnNavigate.InvokeAsync(m))">
<span class="text-muted me-2">Line @m.StartLineNumber</span>@m.Message
</button>
<code class="text-muted small">@m.Code</code>
</li>
}
</ul>
</div>
}
@code {
[Parameter, EditorRequired] public IReadOnlyList<DiagnosticMarker> Markers { get; set; } = Array.Empty<DiagnosticMarker>();
[Parameter] public EventCallback<DiagnosticMarker> OnNavigate { get; set; }
private int _errorCount;
private int _warningCount;
private int _infoCount;
protected override void OnParametersSet()
{
_errorCount = Markers.Count(m => m.Severity >= 8);
_warningCount = Markers.Count(m => m.Severity == 4);
_infoCount = Markers.Count(m => m.Severity > 0 && m.Severity < 4);
}
private static string SeverityBadge(int sev) => sev switch
{
>= 8 => "bg-danger",
4 => "bg-warning text-dark",
_ => "bg-info text-dark"
};
private static string SeverityLabel(int sev) => sev switch
{
>= 8 => "Error",
4 => "Warning",
_ => "Info"
};
}

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