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:
@@ -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><AuthorizeView></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 & 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><redacted></c> or
|
||||
/// <c><redacted: redactor error></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><input type="datetime-local"></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">
|
||||
← 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 4–5 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><ul></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><input type="datetime-local"></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 & 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 →</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 →</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 > 0</c>; the Stuck
|
||||
/// tile gets a warning border when <c>StuckCount > 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">
|
||||
☰
|
||||
</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">▮</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">← 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">
|
||||
← 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">
|
||||
← 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<string?></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);
|
||||
}
|
||||
}
|
||||
+391
@@ -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>
|
||||
+121
@@ -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">→</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">→</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">→</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;
|
||||
}
|
||||
}
|
||||
+94
@@ -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">…</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;
|
||||
}
|
||||
}
|
||||
+796
@@ -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">← 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">← 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));
|
||||
}
|
||||
+53
@@ -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">← 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">← 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">← 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">← 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">← 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><input type="datetime-local"></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 →</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 & 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 & 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()
|
||||
};
|
||||
}
|
||||
+209
@@ -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";
|
||||
}
|
||||
}
|
||||
+190
@@ -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">← 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");
|
||||
}
|
||||
+137
@@ -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 <@r.EmailAddress></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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+741
@@ -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));
|
||||
}
|
||||
+235
@@ -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>
|
||||
}
|
||||
+524
@@ -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=<status></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><input type="datetime-local"></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"] > 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">×</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">…</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)–@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><li></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
Reference in New Issue
Block a user