diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor
index 4a0814d..1c5616e 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor
@@ -4,6 +4,8 @@
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.MxGateway.Contracts.Proto
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
+@inject AuthenticationStateProvider AuthenticationStateProvider
+@inject IDashboardSessionAdminService SessionAdminService
Dashboard Session
@@ -25,9 +27,33 @@ else
Session Details
@CurrentSession.SessionId
-
+
+
+ @if (CanManage)
+ {
+
+
+
+
+ }
+
+ @if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage))
+ {
+
+ @ResultMessage
+
+ }
+
Session
@@ -124,6 +150,23 @@ else
private string? _subscribedSessionId;
private readonly LinkedList
_recentEvents = new();
+ private bool CanManage { get; set; }
+
+ private bool IsBusy { get; set; }
+
+ private string? ResultMessage { get; set; }
+
+ private bool LastOperationSucceeded { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ await base.OnInitializedAsync().ConfigureAwait(false);
+
+ AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
+ .ConfigureAwait(false);
+ CanManage = SessionAdminService.CanManage(authenticationState.User);
+ }
+
protected override async Task OnParametersSetAsync()
{
if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
@@ -133,6 +176,40 @@ else
}
}
+ private Task CloseSessionAsync()
+ {
+ return RunAdminActionAsync(user => SessionAdminService.CloseSessionAsync(user, SessionId, CancellationToken.None));
+ }
+
+ private Task KillWorkerAsync()
+ {
+ return RunAdminActionAsync(user => SessionAdminService.KillWorkerAsync(user, SessionId, CancellationToken.None));
+ }
+
+ private async Task RunAdminActionAsync(
+ Func> action)
+ {
+ if (IsBusy)
+ {
+ return;
+ }
+
+ IsBusy = true;
+ try
+ {
+ AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
+ .ConfigureAwait(false);
+ CanManage = SessionAdminService.CanManage(authenticationState.User);
+ DashboardSessionAdminResult result = await action(authenticationState.User).ConfigureAwait(false);
+ ResultMessage = result.Message;
+ LastOperationSucceeded = result.Succeeded;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
private async Task AttachEventsHubAsync()
{
if (string.IsNullOrWhiteSpace(SessionId))
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor
index e8df4dc..c4d801c 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor
@@ -1,5 +1,7 @@
@page "/sessions"
@inherits DashboardPageBase
+@inject AuthenticationStateProvider AuthenticationStateProvider
+@inject IDashboardSessionAdminService SessionAdminService
Dashboard Sessions
@@ -16,6 +18,13 @@ else
+ @if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage))
+ {
+
+ @ResultMessage
+
+ }
+
@if (Snapshot.Sessions.Count == 0)
{
@@ -37,6 +46,10 @@ else
Activity |
Heartbeat |
Fault |
+ @if (CanManage)
+ {
+ Actions |
+ }
@@ -59,6 +72,23 @@ else
@DashboardDisplay.DateTime(session.LastClientActivityAt) |
@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt) |
@DashboardDisplay.Text(session.LastFault) |
+ @if (CanManage)
+ {
+
+
+
+
+
+ |
+ }
}
@@ -67,3 +97,56 @@ else
}
}
+
+@code {
+ private bool CanManage { get; set; }
+
+ private bool IsBusy { get; set; }
+
+ private string? ResultMessage { get; set; }
+
+ private bool LastOperationSucceeded { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ await base.OnInitializedAsync().ConfigureAwait(false);
+
+ AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
+ .ConfigureAwait(false);
+ CanManage = SessionAdminService.CanManage(authenticationState.User);
+ }
+
+ private Task CloseSessionAsync(string sessionId)
+ {
+ return RunActionAsync(user => SessionAdminService.CloseSessionAsync(user, sessionId, CancellationToken.None));
+ }
+
+ private Task KillWorkerAsync(string sessionId)
+ {
+ return RunActionAsync(user => SessionAdminService.KillWorkerAsync(user, sessionId, CancellationToken.None));
+ }
+
+ private async Task RunActionAsync(
+ Func> action)
+ {
+ if (IsBusy)
+ {
+ return;
+ }
+
+ IsBusy = true;
+ try
+ {
+ AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
+ .ConfigureAwait(false);
+ CanManage = SessionAdminService.CanManage(authenticationState.User);
+ DashboardSessionAdminResult result = await action(authenticationState.User).ConfigureAwait(false);
+ ResultMessage = result.Message;
+ LastOperationSucceeded = result.Succeeded;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor
index 4811852..5fd3edb 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor
@@ -1,5 +1,7 @@
@page "/workers"
@inherits DashboardPageBase
+@inject AuthenticationStateProvider AuthenticationStateProvider
+@inject IDashboardSessionAdminService SessionAdminService
Dashboard Workers
@@ -16,6 +18,13 @@ else
+ @if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage))
+ {
+
+ @ResultMessage
+
+ }
+
@if (Snapshot.Workers.Count == 0)
{
@@ -32,6 +41,10 @@ else
Session |
Heartbeat |
Fault |
+ @if (CanManage)
+ {
+ Actions |
+ }
@@ -43,6 +56,16 @@ else
@worker.SessionId |
@DashboardDisplay.DateTime(worker.LastHeartbeatAt) |
@DashboardDisplay.Text(worker.LastFault) |
+ @if (CanManage)
+ {
+
+
+ |
+ }
}
@@ -51,3 +74,47 @@ else
}
}
+
+@code {
+ private bool CanManage { get; set; }
+
+ private bool IsBusy { get; set; }
+
+ private string? ResultMessage { get; set; }
+
+ private bool LastOperationSucceeded { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ await base.OnInitializedAsync().ConfigureAwait(false);
+
+ AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
+ .ConfigureAwait(false);
+ CanManage = SessionAdminService.CanManage(authenticationState.User);
+ }
+
+ private async Task KillWorkerAsync(string sessionId)
+ {
+ if (IsBusy)
+ {
+ return;
+ }
+
+ IsBusy = true;
+ try
+ {
+ AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
+ .ConfigureAwait(false);
+ CanManage = SessionAdminService.CanManage(authenticationState.User);
+ DashboardSessionAdminResult result = await SessionAdminService
+ .KillWorkerAsync(authenticationState.User, sessionId, CancellationToken.None)
+ .ConfigureAwait(false);
+ ResultMessage = result.Message;
+ LastOperationSucceeded = result.Succeeded;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
index 1382c11..7380b97 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
@@ -20,6 +20,7 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddScoped();
services.AddSingleton();
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs
new file mode 100644
index 0000000..ed7f05a
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminResult.cs
@@ -0,0 +1,16 @@
+namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
+
+public sealed record DashboardSessionAdminResult(
+ bool Succeeded,
+ string Message)
+{
+ public static DashboardSessionAdminResult Success(string message)
+ {
+ return new DashboardSessionAdminResult(true, message);
+ }
+
+ public static DashboardSessionAdminResult Fail(string message)
+ {
+ return new DashboardSessionAdminResult(false, message);
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs
new file mode 100644
index 0000000..7fc6c80
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs
@@ -0,0 +1,136 @@
+using System.Security.Claims;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.MxGateway.Server.Sessions;
+
+namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
+
+public sealed class DashboardSessionAdminService(
+ ISessionManager sessionManager,
+ IHttpContextAccessor httpContextAccessor,
+ ILogger? logger = null) : IDashboardSessionAdminService
+{
+ private const string UnauthorizedMessage = "Sign in as an Admin to close sessions or kill workers.";
+ private const string KillReason = "dashboard-admin-kill";
+
+ private readonly ILogger _logger =
+ logger ?? NullLogger.Instance;
+
+ public bool CanManage(ClaimsPrincipal user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ return user.Identity?.IsAuthenticated == true
+ && user.IsInRole(DashboardRoles.Admin);
+ }
+
+ public async Task CloseSessionAsync(
+ ClaimsPrincipal user,
+ string sessionId,
+ CancellationToken cancellationToken)
+ {
+ if (!CanManage(user))
+ {
+ return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
+ }
+
+ if (string.IsNullOrWhiteSpace(sessionId))
+ {
+ return DashboardSessionAdminResult.Fail("Session id is required.");
+ }
+
+ string actor = ResolveActor(user);
+ try
+ {
+ SessionCloseResult result = await sessionManager
+ .CloseSessionAsync(sessionId, cancellationToken)
+ .ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "Dashboard admin {Actor} closed session {SessionId} from {RemoteAddress}; alreadyClosed={AlreadyClosed}.",
+ actor,
+ sessionId,
+ ResolveRemoteAddress(),
+ result.AlreadyClosed);
+
+ return DashboardSessionAdminResult.Success(
+ result.AlreadyClosed
+ ? $"Session {sessionId} was already closed."
+ : $"Session {sessionId} closed.");
+ }
+ catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
+ {
+ return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
+ }
+ catch (SessionManagerException exception)
+ {
+ _logger.LogWarning(
+ exception,
+ "Dashboard admin {Actor} close failed for session {SessionId}.",
+ actor,
+ sessionId);
+ return DashboardSessionAdminResult.Fail(
+ $"Close failed: {exception.Message}");
+ }
+ }
+
+ public async Task KillWorkerAsync(
+ ClaimsPrincipal user,
+ string sessionId,
+ CancellationToken cancellationToken)
+ {
+ if (!CanManage(user))
+ {
+ return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
+ }
+
+ if (string.IsNullOrWhiteSpace(sessionId))
+ {
+ return DashboardSessionAdminResult.Fail("Session id is required.");
+ }
+
+ string actor = ResolveActor(user);
+ try
+ {
+ SessionCloseResult result = await sessionManager
+ .KillWorkerAsync(sessionId, KillReason, cancellationToken)
+ .ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "Dashboard admin {Actor} killed worker for session {SessionId} from {RemoteAddress}; alreadyClosed={AlreadyClosed}.",
+ actor,
+ sessionId,
+ ResolveRemoteAddress(),
+ result.AlreadyClosed);
+
+ return DashboardSessionAdminResult.Success(
+ result.AlreadyClosed
+ ? $"Session {sessionId} was already closed."
+ : $"Worker for session {sessionId} killed.");
+ }
+ catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
+ {
+ return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
+ }
+ catch (SessionManagerException exception)
+ {
+ _logger.LogWarning(
+ exception,
+ "Dashboard admin {Actor} kill failed for session {SessionId}.",
+ actor,
+ sessionId);
+ return DashboardSessionAdminResult.Fail(
+ $"Kill failed: {exception.Message}");
+ }
+ }
+
+ private static string ResolveActor(ClaimsPrincipal user)
+ {
+ return user.Identity?.Name ?? "";
+ }
+
+ private string? ResolveRemoteAddress()
+ {
+ return httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAdminService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAdminService.cs
new file mode 100644
index 0000000..d313609
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAdminService.cs
@@ -0,0 +1,18 @@
+using System.Security.Claims;
+
+namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
+
+public interface IDashboardSessionAdminService
+{
+ bool CanManage(ClaimsPrincipal user);
+
+ Task CloseSessionAsync(
+ ClaimsPrincipal user,
+ string sessionId,
+ CancellationToken cancellationToken);
+
+ Task KillWorkerAsync(
+ ClaimsPrincipal user,
+ string sessionId,
+ CancellationToken cancellationToken);
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs
index 0c137ee..2d84f26 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs
@@ -49,6 +49,19 @@ public interface ISessionManager
string sessionId,
CancellationToken cancellationToken);
+ ///
+ /// Forcefully terminates a session's worker without attempting graceful shutdown,
+ /// transitions the session to Closed, and removes it from the registry.
+ ///
+ /// Identifier of the session whose worker to kill.
+ /// Reason for killing the worker (recorded in logs/audit).
+ /// Token to cancel the asynchronous operation.
+ /// The result of closing the session.
+ Task KillWorkerAsync(
+ string sessionId,
+ string reason,
+ CancellationToken cancellationToken);
+
/// Closes all sessions with expired leases at the specified time.
/// The current time to evaluate expiration against.
/// Token to cancel the asynchronous operation.
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs
index a648762..68807f2 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs
@@ -203,6 +203,56 @@ public sealed class SessionManager : ISessionManager
return result;
}
+ ///
+ /// Forcefully terminates a session's worker without attempting graceful shutdown.
+ /// Mirrors the registry/metrics cleanup that
+ /// performs after a successful close, but skips the WorkerClient.ShutdownAsync
+ /// step that would otherwise attempt.
+ ///
+ /// Session identifier.
+ /// Reason recorded for the kill.
+ /// Cancellation token.
+ /// Session close result.
+ public async Task KillWorkerAsync(
+ string sessionId,
+ string reason,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(reason);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ GatewaySession session = GetRequiredSession(sessionId);
+ bool wasClosed = session.State == SessionState.Closed;
+
+ try
+ {
+ session.KillWorker(reason);
+ }
+ catch (Exception exception)
+ {
+ session.MarkFaulted(exception.Message);
+ _metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString());
+ await RemoveSessionAsync(session).ConfigureAwait(false);
+ throw new SessionManagerException(
+ SessionManagerErrorCode.CloseFailed,
+ $"Failed to kill worker for session {sessionId}.",
+ exception);
+ }
+
+ if (!wasClosed)
+ {
+ _metrics.SessionClosed();
+ }
+
+ await RemoveSessionAsync(session).ConfigureAwait(false);
+ _logger.LogInformation(
+ "Worker for session {SessionId} killed; reason={Reason}.",
+ sessionId,
+ reason);
+
+ return new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: wasClosed);
+ }
+
///
/// Closes all sessions with expired leases asynchronously.
///
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs
new file mode 100644
index 0000000..74dc162
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs
@@ -0,0 +1,223 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Server.Dashboard;
+using ZB.MOM.WW.MxGateway.Server.Sessions;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
+
+public sealed class DashboardSessionAdminServiceTests
+{
+ [Fact]
+ public async Task CloseSessionAsync_ViewerCannotManage()
+ {
+ FakeSessionManager sessionManager = new();
+ DashboardSessionAdminService service = CreateService(sessionManager);
+
+ DashboardSessionAdminResult result = await service.CloseSessionAsync(
+ CreateUser(DashboardRoles.Viewer),
+ "session-1",
+ CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(0, sessionManager.CloseCount);
+ }
+
+ [Fact]
+ public async Task CloseSessionAsync_AdminClosesSession()
+ {
+ FakeSessionManager sessionManager = new();
+ DashboardSessionAdminService service = CreateService(sessionManager);
+
+ DashboardSessionAdminResult result = await service.CloseSessionAsync(
+ CreateUser(DashboardRoles.Admin),
+ "session-1",
+ CancellationToken.None);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(1, sessionManager.CloseCount);
+ Assert.Equal("session-1", sessionManager.LastClosedSessionId);
+ }
+
+ [Fact]
+ public async Task CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError()
+ {
+ FakeSessionManager sessionManager = new()
+ {
+ CloseThrowsNotFound = true,
+ };
+ DashboardSessionAdminService service = CreateService(sessionManager);
+
+ DashboardSessionAdminResult result = await service.CloseSessionAsync(
+ CreateUser(DashboardRoles.Admin),
+ "session-missing",
+ CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task KillWorkerAsync_ViewerCannotManage()
+ {
+ FakeSessionManager sessionManager = new();
+ DashboardSessionAdminService service = CreateService(sessionManager);
+
+ DashboardSessionAdminResult result = await service.KillWorkerAsync(
+ CreateUser(DashboardRoles.Viewer),
+ "session-1",
+ CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(0, sessionManager.KillCount);
+ }
+
+ [Fact]
+ public async Task KillWorkerAsync_AdminKillsWorker()
+ {
+ FakeSessionManager sessionManager = new();
+ DashboardSessionAdminService service = CreateService(sessionManager);
+
+ DashboardSessionAdminResult result = await service.KillWorkerAsync(
+ CreateUser(DashboardRoles.Admin),
+ "session-1",
+ CancellationToken.None);
+
+ Assert.True(result.Succeeded);
+ Assert.Equal(1, sessionManager.KillCount);
+ Assert.Equal("session-1", sessionManager.LastKilledSessionId);
+ Assert.False(string.IsNullOrWhiteSpace(sessionManager.LastKillReason));
+ }
+
+ [Fact]
+ public async Task KillWorkerAsync_BlankSessionId_ReturnsFailure()
+ {
+ FakeSessionManager sessionManager = new();
+ DashboardSessionAdminService service = CreateService(sessionManager);
+
+ DashboardSessionAdminResult result = await service.KillWorkerAsync(
+ CreateUser(DashboardRoles.Admin),
+ " ",
+ CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(0, sessionManager.KillCount);
+ }
+
+ [Fact]
+ public void CanManage_RejectsUnauthenticatedAndViewer()
+ {
+ DashboardSessionAdminService service = CreateService(new FakeSessionManager());
+
+ Assert.False(service.CanManage(new ClaimsPrincipal(new ClaimsIdentity())));
+ Assert.False(service.CanManage(CreateUser(DashboardRoles.Viewer)));
+ Assert.True(service.CanManage(CreateUser(DashboardRoles.Admin)));
+ }
+
+ private static DashboardSessionAdminService CreateService(ISessionManager sessionManager)
+ {
+ DefaultHttpContext httpContext = new();
+ httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
+
+ return new DashboardSessionAdminService(
+ sessionManager,
+ new HttpContextAccessor { HttpContext = httpContext });
+ }
+
+ private static ClaimsPrincipal CreateUser(string role)
+ {
+ ClaimsIdentity identity = new(
+ [new Claim(ClaimTypes.Name, "tester"), new Claim(ClaimTypes.Role, role)],
+ DashboardAuthenticationDefaults.AuthenticationScheme,
+ ClaimTypes.Name,
+ ClaimTypes.Role);
+
+ return new ClaimsPrincipal(identity);
+ }
+
+ private sealed class FakeSessionManager : ISessionManager
+ {
+ public int CloseCount { get; private set; }
+
+ public int KillCount { get; private set; }
+
+ public string? LastClosedSessionId { get; private set; }
+
+ public string? LastKilledSessionId { get; private set; }
+
+ public string? LastKillReason { get; private set; }
+
+ public bool CloseThrowsNotFound { get; init; }
+
+ public Task OpenSessionAsync(
+ SessionOpenRequest request,
+ string? clientIdentity,
+ CancellationToken cancellationToken)
+ {
+ throw new NotSupportedException();
+ }
+
+ public bool TryGetSession(
+ string sessionId,
+ [MaybeNullWhen(false)] out GatewaySession session)
+ {
+ session = null;
+ return false;
+ }
+
+ public Task InvokeAsync(
+ string sessionId,
+ WorkerCommand command,
+ CancellationToken cancellationToken)
+ {
+ throw new NotSupportedException();
+ }
+
+ public IAsyncEnumerable ReadEventsAsync(
+ string sessionId,
+ CancellationToken cancellationToken)
+ {
+ throw new NotSupportedException();
+ }
+
+ public Task CloseSessionAsync(
+ string sessionId,
+ CancellationToken cancellationToken)
+ {
+ CloseCount++;
+ LastClosedSessionId = sessionId;
+ if (CloseThrowsNotFound)
+ {
+ throw new SessionManagerException(
+ SessionManagerErrorCode.SessionNotFound,
+ $"Session {sessionId} was not found.");
+ }
+
+ return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+ }
+
+ public Task KillWorkerAsync(
+ string sessionId,
+ string reason,
+ CancellationToken cancellationToken)
+ {
+ KillCount++;
+ LastKilledSessionId = sessionId;
+ LastKillReason = reason;
+ return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+ }
+
+ public Task CloseExpiredLeasesAsync(
+ DateTimeOffset now,
+ CancellationToken cancellationToken)
+ {
+ return Task.FromResult(0);
+ }
+
+ public Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs
index fc11e9c..990195a 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs
@@ -505,6 +505,15 @@ public sealed class EventStreamServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
+ ///
+ public Task KillWorkerAsync(
+ string sessionId,
+ string reason,
+ CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+ }
+
///
public Task CloseExpiredLeasesAsync(
DateTimeOffset now,
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs
index 6b84b4d..9e99dd3 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs
@@ -898,6 +898,12 @@ public sealed class MxAccessGatewayServiceConstraintTests
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+ public Task KillWorkerAsync(
+ string sessionId,
+ string reason,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+
public Task CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs
index 3aec895..3b4bcd5 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs
@@ -539,6 +539,15 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
+ ///
+ public Task KillWorkerAsync(
+ string sessionId,
+ string reason,
+ CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+ }
+
///
public Task CloseExpiredLeasesAsync(
DateTimeOffset now,
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs
index 5c64db7..7fea357 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs
@@ -463,6 +463,38 @@ public sealed class SessionManagerTests
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
+ /// Verifies that killing a worker removes the session from the registry without calling shutdown.
+ [Fact]
+ public async Task KillWorkerAsync_KillsWorkerAndRemovesSession()
+ {
+ FakeWorkerClient workerClient = new();
+ using GatewayMetrics metrics = new();
+ SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient), metrics: metrics);
+ GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
+
+ SessionCloseResult result = await manager.KillWorkerAsync(session.SessionId, "test-kill", CancellationToken.None);
+
+ Assert.False(result.AlreadyClosed);
+ Assert.Equal(SessionState.Closed, result.FinalState);
+ Assert.Equal(1, workerClient.KillCount);
+ Assert.Equal(0, workerClient.ShutdownCount);
+ Assert.False(manager.TryGetSession(session.SessionId, out _));
+ Assert.Equal(1, metrics.GetSnapshot().SessionsClosed);
+ Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
+ }
+
+ /// Verifies that killing the worker for an unknown session raises SessionNotFound.
+ [Fact]
+ public async Task KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound()
+ {
+ SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient()));
+
+ SessionManagerException exception = await Assert.ThrowsAsync(
+ async () => await manager.KillWorkerAsync("session-missing", "test-kill", CancellationToken.None));
+
+ Assert.Equal(SessionManagerErrorCode.SessionNotFound, exception.ErrorCode);
+ }
+
/// Verifies that when worker creation fails, the session is removed from the registry.
[Fact]
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs
index e7cc3c5..c171b3a 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs
@@ -458,6 +458,15 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
+ ///
+ public Task KillWorkerAsync(
+ string sessionId,
+ string reason,
+ CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
+ }
+
///
public Task CloseExpiredLeasesAsync(
DateTimeOffset now,