Compare commits

...

6 Commits

Author SHA1 Message Date
Joseph Doherty 7b86bab705 Merge remote-tracking branch 'origin/main' into agent-1/issue-16-implement-blazor-server-dashboard 2026-04-26 18:38:19 -04:00
Joseph Doherty 56886c3b4e Implement Blazor Server dashboard 2026-04-26 18:37:16 -04:00
dohertj2 a3ccd5c80b Merge pull request #79 from agent-3/issue-19-gateway-e2e-smoke-with-fake-worker
Issue #19: gateway end-to-end smoke with fake worker
2026-04-26 18:34:54 -04:00
dohertj2 0fd954d94c Merge pull request #78 from agent-2/issue-27-implement-additem-additem2-removeitem
Issue #27: implement AddItem, AddItem2, RemoveItem
2026-04-26 18:32:13 -04:00
Joseph Doherty 91f2d8dc14 Merge remote-tracking branch 'origin/main' into agent-3/issue-19-gateway-e2e-smoke-with-fake-worker 2026-04-26 18:31:49 -04:00
Joseph Doherty fb425da009 Add gateway fake worker end-to-end smoke 2026-04-26 18:30:11 -04:00
28 changed files with 1354 additions and 95 deletions
+7
View File
@@ -28,6 +28,12 @@ Session-level tests can connect the harness to the pipe created by
`WorkerClient` tests can use `CreateConnectedPairAsync` to create both pipe ends
inside the test.
`GatewayEndToEndFakeWorkerSmokeTests` composes the real gRPC service,
`SessionManager`, `SessionWorkerClientFactory`, `WorkerClient`, and
`EventStreamService` with a scripted fake worker launcher. The smoke test covers
`OpenSession`, `Register`, `AddItem`, `Advise`, one streamed `OnDataChange`
event, and `CloseSession` without loading MXAccess COM.
## Focused Commands
Run the fake worker tests after changing gateway worker IPC, session startup, or
@@ -36,6 +42,7 @@ event streaming behavior:
```bash
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~SessionWorkerClientFactoryFakeWorkerTests
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
```
Run the gateway test project after shared gateway test infrastructure changes:
+33 -30
View File
@@ -34,9 +34,13 @@ SignalR circuit. Bootstrap is sufficient for a basic dashboard.
## Hosting Model
The dashboard is hosted by `MxGateway.Server` alongside the gRPC API.
The dashboard is hosted by `MxGateway.Server` alongside the gRPC API. When
`MxGateway:Dashboard:Enabled` is `true`, `MapGatewayDashboard()` maps the
configured `Dashboard:PathBase` to the Blazor Server app and maps the login,
logout, and access-denied HTTP endpoints beside it. When dashboard hosting is
disabled, those routes are not mapped.
Suggested endpoint layout:
Endpoint layout:
```text
/dashboard
@@ -45,7 +49,7 @@ Suggested endpoint layout:
/dashboard/workers
/dashboard/events
/dashboard/settings
/_blazor
/dashboard/_blazor
```
The app should redirect `/` to `/dashboard` only if the deployment wants the
@@ -59,9 +63,10 @@ MxGateway.Server
Components/
App.razor
Routes.razor
DashboardPageBase.cs
DashboardDisplay.cs
Layout/
DashboardLayout.razor
NavMenu.razor
Pages/
DashboardHome.razor
SessionsPage.razor
@@ -69,26 +74,21 @@ MxGateway.Server
WorkersPage.razor
EventsPage.razor
SettingsPage.razor
Components/
Shared/
MetricCard.razor
SessionTable.razor
WorkerTable.razor
EventRatePanel.razor
StatusBadge.razor
FaultList.razor
Services/
DashboardSnapshotService.cs
DashboardUpdateHub.cs
DashboardAuthorization.cs
Models/
DashboardSnapshot.cs
SessionSummary.cs
WorkerSummary.cs
MetricSummary.cs
DashboardSnapshotService.cs
DashboardAuthorizationHandler.cs
DashboardAuthenticator.cs
DashboardSnapshot.cs
DashboardSessionSummary.cs
DashboardWorkerSummary.cs
DashboardMetricSummary.cs
```
`DashboardUpdateHub` here means an internal application update service, not a
separate public SignalR hub unless implementation proves one is needed. Blazor
Server already uses SignalR for UI circuits.
Blazor Server provides the SignalR circuit for UI updates. The implementation
does not add a separate public dashboard hub.
## Dashboard Data Source
@@ -137,7 +137,7 @@ gateway internals.
Use Blazor Server component state updates for real-time dashboard refresh.
Recommended pattern:
Implemented pattern:
1. Page/component subscribes to `WatchSnapshotsAsync`.
2. Snapshot service emits updates from a bounded channel or timer.
@@ -147,10 +147,8 @@ Recommended pattern:
Default update cadence:
- immediate update on session create/close/fault,
- immediate update on worker fault,
- periodic metrics refresh every 1 second,
- event-rate windows updated every 1 second.
- event counters update on the next snapshot tick.
Avoid pushing every MXAccess data-change event to the dashboard. Aggregate event
counts and rates instead.
@@ -320,7 +318,9 @@ Suggested configuration:
## Styling
Use Bootstrap utility classes and a small local stylesheet.
The dashboard serves Bootstrap 5.3.3 assets from
`src/MxGateway.Server/wwwroot/lib/bootstrap/` and local layout/status styling
from `src/MxGateway.Server/wwwroot/css/dashboard.css`.
Recommended visual language:
@@ -361,15 +361,18 @@ Integration tests should verify:
## Initial Implementation Slice
The first dashboard slice should implement:
The first dashboard slice implements:
1. Blazor Server hosting in `MxGateway.Server`.
2. Bootstrap static assets.
2. local Bootstrap static assets.
3. dashboard configuration binding.
4. dashboard auth using API key login and HTTP-only cookie.
5. read-only `DashboardSnapshotService`.
6. home page with metric cards.
7. sessions page with active session table.
7. sessions page with active session table and session details.
8. workers page with worker table.
9. 1-second realtime refresh through Blazor Server.
10. redaction tests for secrets.
9. events page with aggregate counters.
10. settings page with redacted effective configuration.
11. periodic realtime refresh through Blazor Server.
12. route-mapping tests, disabled-dashboard tests, auth tests, and snapshot
projection/redaction tests.
+10 -1
View File
@@ -109,13 +109,22 @@ histograms through .NET `Meter` and a snapshot API that dashboard services can
project without binding to a metrics exporter.
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
effective configuration into immutable DTOs for read-only dashboard rendering.
The Blazor Server dashboard renders those snapshots at `/dashboard`,
`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, and
`/dashboard/settings`. Components subscribe to
`IDashboardSnapshotService.WatchSnapshotsAsync()` and update on the configured
snapshot interval without mutating session or worker state. The dashboard uses
local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not
use a Blazor UI component library.
Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login`
accepts the API key in a form body, validates the configured `admin` scope,
and issues an HTTP-only secure cookie for subsequent dashboard requests.
`/dashboard/logout` clears that cookie. Login and logout posts validate
anti-forgery tokens, and API keys are never accepted through query strings.
`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for
loopback requests only when explicitly enabled.
loopback requests only when explicitly enabled. Setting
`MxGateway:Dashboard:Enabled` to `false` leaves the dashboard routes unmapped.
### Worker Process
@@ -0,0 +1,34 @@
@inject IOptions<GatewayOptions> GatewayOptions
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="@DashboardBaseHref" />
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body class="dashboard-body">
<Routes @rendermode="InteractiveServer" />
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@code {
private string DashboardBaseHref
{
get
{
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
return $"{pathBase}/";
}
}
}
@@ -0,0 +1,30 @@
namespace MxGateway.Server.Dashboard.Components;
public static class DashboardDisplay
{
public static string DateTime(DateTimeOffset? value)
{
return value.HasValue
? value.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", System.Globalization.CultureInfo.InvariantCulture)
: "-";
}
public static string Duration(TimeSpan value)
{
return value.TotalDays >= 1
? value.ToString(@"d\.hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture)
: value.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture);
}
public static string Text(string? value)
{
return string.IsNullOrWhiteSpace(value) ? "-" : value;
}
public static long MetricValue(DashboardSnapshot snapshot, string name, string? dimension = null)
{
return snapshot.Metrics.FirstOrDefault(metric =>
string.Equals(metric.Name, name, StringComparison.Ordinal)
&& string.Equals(metric.Dimension, dimension, StringComparison.Ordinal))?.Value ?? 0;
}
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Components;
namespace MxGateway.Server.Dashboard.Components;
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
{
private readonly CancellationTokenSource _disposeCancellation = new();
private Task? _watchTask;
[Inject]
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
protected DashboardSnapshot? Snapshot { get; private set; }
protected override void OnInitialized()
{
_watchTask = WatchSnapshotsAsync();
}
public async ValueTask DisposeAsync()
{
await _disposeCancellation.CancelAsync().ConfigureAwait(false);
if (_watchTask is not null)
{
await _watchTask.ConfigureAwait(false);
}
_disposeCancellation.Dispose();
GC.SuppressFinalize(this);
}
private async Task WatchSnapshotsAsync()
{
try
{
await foreach (DashboardSnapshot snapshot in SnapshotService
.WatchSnapshotsAsync(_disposeCancellation.Token)
.ConfigureAwait(false))
{
Snapshot = snapshot;
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_disposeCancellation.IsCancellationRequested)
{
}
}
}
@@ -0,0 +1,53 @@
@inherits LayoutComponentBase
@inject IOptions<GatewayOptions> GatewayOptions
<div class="dashboard-shell">
<nav class="navbar navbar-expand-lg bg-body border-bottom dashboard-navbar">
<div class="container-fluid">
<a class="navbar-brand" href="">MXAccess Gateway</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#dashboardNav"
aria-controls="dashboardNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="dashboardNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">Overview</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="sessions">Sessions</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="workers">Workers</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="events">Events</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="settings">Settings</NavLink>
</li>
</ul>
<form method="post" action="@DashboardPath("/logout")" class="d-flex">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
</div>
</div>
</nav>
<main class="container-fluid dashboard-content">
@Body
</main>
</div>
@code {
private string DashboardPath(string relativePath)
{
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
return $"{pathBase}{relativePath}";
}
}
@@ -0,0 +1,37 @@
@page "/"
@inherits DashboardPageBase
<PageTitle>MXAccess Gateway Dashboard</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading dashboard snapshot.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Overview</h1>
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
</div>
<StatusBadge Text="@Snapshot.GatewayStatus" />
</div>
<section class="metric-grid">
<MetricCard Label="Uptime" Value="@DashboardDisplay.Duration(Snapshot.GatewayUptime)" Detail="@Snapshot.GatewayVersion" />
<MetricCard Label="Open Sessions" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.sessions.open").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Workers Running" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.workers.running").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Event Queue Depth" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.queue.depth").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Commands Failed" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.commands.failed").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Events Received" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Faults" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.faults").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Recent Faults</h2>
</div>
<FaultList Faults="@Snapshot.Faults" />
</section>
}
@@ -0,0 +1,64 @@
@page "/events"
@inherits DashboardPageBase
<PageTitle>Dashboard Events</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading event diagnostics.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Events</h1>
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
</div>
</div>
<section class="metric-grid compact">
<MetricCard Label="Events Received" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Event Queue Depth" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.queue.depth").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Stream Disconnects" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.grpc.streams.disconnected").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Event Families</h2>
</div>
@if (EventFamilyMetrics.Count == 0)
{
<div class="empty-state">No event family counters recorded.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Family</th>
<th scope="col">Count</th>
</tr>
</thead>
<tbody>
@foreach (DashboardMetricSummary metric in EventFamilyMetrics)
{
<tr>
<td>@metric.Dimension</td>
<td>@metric.Value</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private IReadOnlyList<DashboardMetricSummary> EventFamilyMetrics => Snapshot?.Metrics
.Where(metric => metric.Name == "mxgateway.events.received" && metric.Dimension is not null)
.OrderBy(metric => metric.Dimension, StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
}
@@ -0,0 +1,69 @@
@page "/sessions/{SessionId}"
@inherits DashboardPageBase
<PageTitle>Dashboard Session</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading session.</div>
}
else if (CurrentSession is null)
{
<section class="dashboard-section">
<h1 class="h4 mb-3">Session Not Found</h1>
<p class="mb-0">The session is not present in the current snapshot.</p>
</section>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Session Details</h1>
<div class="text-secondary"><code>@CurrentSession.SessionId</code></div>
</div>
<StatusBadge Text="@CurrentSession.State.ToString()" />
</div>
<section class="dashboard-section">
<div class="section-heading">
<h2>Session</h2>
</div>
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Backend</th><td>@CurrentSession.BackendName</td></tr>
<tr><th scope="row">Client identity</th><td>@DashboardDisplay.Text(CurrentSession.ClientIdentity)</td></tr>
<tr><th scope="row">Client session</th><td>@DashboardDisplay.Text(CurrentSession.ClientSessionName)</td></tr>
<tr><th scope="row">Client correlation</th><td>@DashboardDisplay.Text(CurrentSession.ClientCorrelationId)</td></tr>
<tr><th scope="row">Opened</th><td>@DashboardDisplay.DateTime(CurrentSession.OpenedAt)</td></tr>
<tr><th scope="row">Last activity</th><td>@DashboardDisplay.DateTime(CurrentSession.LastClientActivityAt)</td></tr>
<tr><th scope="row">Lease expires</th><td>@DashboardDisplay.DateTime(CurrentSession.LeaseExpiresAt)</td></tr>
<tr><th scope="row">Last fault</th><td>@DashboardDisplay.Text(CurrentSession.LastFault)</td></tr>
</tbody>
</table>
</div>
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Worker</h2>
</div>
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Process id</th><td>@(CurrentSession.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td></tr>
<tr><th scope="row">State</th><td><StatusBadge Text="@(CurrentSession.WorkerState?.ToString() ?? "-")" /></td></tr>
<tr><th scope="row">Last heartbeat</th><td>@DashboardDisplay.DateTime(CurrentSession.LastWorkerHeartbeatAt)</td></tr>
</tbody>
</table>
</div>
</section>
}
@code {
[Parameter]
public string SessionId { get; set; } = string.Empty;
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
}
@@ -0,0 +1,67 @@
@page "/sessions"
@inherits DashboardPageBase
<PageTitle>Dashboard Sessions</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading sessions.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Sessions</h1>
<div class="text-secondary">@Snapshot.Sessions.Count session rows</div>
</div>
</div>
<section class="dashboard-section">
@if (Snapshot.Sessions.Count == 0)
{
<div class="empty-state">No sessions are active or retained.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Session</th>
<th scope="col">State</th>
<th scope="col">Client</th>
<th scope="col">Backend</th>
<th scope="col">Worker</th>
<th scope="col">Opened</th>
<th scope="col">Activity</th>
<th scope="col">Heartbeat</th>
<th scope="col">Fault</th>
</tr>
</thead>
<tbody>
@foreach (DashboardSessionSummary session in Snapshot.Sessions)
{
<tr>
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(session.SessionId)}")"><code>@session.SessionId</code></NavLink></td>
<td><StatusBadge Text="@session.State.ToString()" /></td>
<td>@DashboardDisplay.Text(session.ClientIdentity)</td>
<td>@session.BackendName</td>
<td>
@(session.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")
@if (session.WorkerState is not null)
{
<span class="ms-1"><StatusBadge Text="@session.WorkerState.ToString()" /></span>
}
</td>
<td>@DashboardDisplay.DateTime(session.OpenedAt)</td>
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
<td>@DashboardDisplay.Text(session.LastFault)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@@ -0,0 +1,47 @@
@page "/settings"
@inherits DashboardPageBase
<PageTitle>Dashboard Settings</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading settings.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Settings</h1>
<div class="text-secondary">Effective gateway configuration</div>
</div>
</div>
<section class="dashboard-section">
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Authentication mode</th><td>@Snapshot.Configuration.Authentication.Mode</td></tr>
<tr><th scope="row">Auth database</th><td><code>@Snapshot.Configuration.Authentication.SqlitePath</code></td></tr>
<tr><th scope="row">Pepper secret</th><td>@Snapshot.Configuration.Authentication.PepperSecretName</td></tr>
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
<tr><th scope="row">Shutdown timeout</th><td>@Snapshot.Configuration.Worker.ShutdownTimeoutSeconds seconds</td></tr>
<tr><th scope="row">Heartbeat grace</th><td>@Snapshot.Configuration.Worker.HeartbeatGraceSeconds seconds</td></tr>
<tr><th scope="row">Default command timeout</th><td>@Snapshot.Configuration.Sessions.DefaultCommandTimeoutSeconds seconds</td></tr>
<tr><th scope="row">Max sessions</th><td>@Snapshot.Configuration.Sessions.MaxSessions</td></tr>
<tr><th scope="row">Event queue capacity</th><td>@Snapshot.Configuration.Events.QueueCapacity</td></tr>
<tr><th scope="row">Backpressure policy</th><td>@Snapshot.Configuration.Events.BackpressurePolicy</td></tr>
<tr><th scope="row">Dashboard enabled</th><td>@Snapshot.Configuration.Dashboard.Enabled</td></tr>
<tr><th scope="row">Dashboard path</th><td>@Snapshot.Configuration.Dashboard.PathBase</td></tr>
<tr><th scope="row">Require admin scope</th><td>@Snapshot.Configuration.Dashboard.RequireAdminScope</td></tr>
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
<tr><th scope="row">Worker protocol</th><td>@Snapshot.Configuration.Protocol.WorkerProtocolVersion</td></tr>
</tbody>
</table>
</div>
</section>
}
@@ -0,0 +1,53 @@
@page "/workers"
@inherits DashboardPageBase
<PageTitle>Dashboard Workers</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading workers.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Workers</h1>
<div class="text-secondary">@Snapshot.Workers.Count worker rows</div>
</div>
</div>
<section class="dashboard-section">
@if (Snapshot.Workers.Count == 0)
{
<div class="empty-state">No worker processes are attached.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Process</th>
<th scope="col">State</th>
<th scope="col">Session</th>
<th scope="col">Heartbeat</th>
<th scope="col">Fault</th>
</tr>
</thead>
<tbody>
@foreach (DashboardWorkerSummary worker in Snapshot.Workers)
{
<tr>
<td>@(worker.ProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
<td><StatusBadge Text="@worker.State.ToString()" /></td>
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
<td>@DashboardDisplay.Text(worker.LastFault)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@@ -0,0 +1,15 @@
<Router AppAssembly="@typeof(Routes).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(DashboardLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(DashboardLayout)">
<PageTitle>Dashboard - Not Found</PageTitle>
<section class="dashboard-section">
<h1 class="h4 mb-3">Not Found</h1>
<p class="mb-0">The requested dashboard page does not exist.</p>
</section>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,39 @@
@if (Faults.Count == 0)
{
<div class="empty-state">No faults recorded.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Observed</th>
<th scope="col">Source</th>
<th scope="col">Session</th>
<th scope="col">Worker</th>
<th scope="col">State</th>
<th scope="col">Message</th>
</tr>
</thead>
<tbody>
@foreach (DashboardFaultSummary fault in Faults)
{
<tr>
<td>@DashboardDisplay.DateTime(fault.ObservedAt)</td>
<td>@fault.Source</td>
<td><code>@DashboardDisplay.Text(fault.SessionId)</code></td>
<td>@(fault.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
<td><StatusBadge Text="@fault.State" /></td>
<td>@fault.Message</td>
</tr>
}
</tbody>
</table>
</div>
}
@code {
[Parameter]
public IReadOnlyList<DashboardFaultSummary> Faults { get; set; } = [];
}
@@ -0,0 +1,21 @@
<div class="card metric-card h-100">
<div class="card-body">
<div class="metric-label">@Label</div>
<div class="metric-value">@Value</div>
@if (!string.IsNullOrWhiteSpace(Detail))
{
<div class="metric-detail">@Detail</div>
}
</div>
</div>
@code {
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public string Value { get; set; } = string.Empty;
[Parameter]
public string? Detail { get; set; }
}
@@ -0,0 +1,15 @@
<span class="badge @CssClass">@Text</span>
@code {
[Parameter]
public string? Text { get; set; }
private string CssClass => Text switch
{
"Ready" or "Healthy" => "text-bg-success",
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info",
"Closed" => "text-bg-secondary",
"Faulted" => "text-bg-danger",
_ => "text-bg-light text-dark border"
};
}
@@ -0,0 +1,12 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Options
@using MxGateway.Contracts.Proto
@using MxGateway.Server.Configuration
@using MxGateway.Server.Dashboard
@using MxGateway.Server.Dashboard.Components.Layout
@using MxGateway.Server.Dashboard.Components.Shared
@using MxGateway.Server.Workers
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.HttpResults;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard.Components;
namespace MxGateway.Server.Dashboard;
@@ -22,13 +23,6 @@ public static class DashboardEndpointRouteBuilderExtensions
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
dashboard.MapGet(
"/",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardSnapshotService snapshotService) =>
GetDashboardHomeAsync(httpContext, antiforgery, snapshotService, pathBase))
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
.WithName("DashboardHome");
dashboard.MapGet(
"/login",
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
@@ -54,36 +48,13 @@ public static class DashboardEndpointRouteBuilderExtensions
.AllowAnonymous()
.WithName("DashboardAccessDenied");
dashboard.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
return endpoints;
}
private static ContentHttpResult GetDashboardHomeAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
IDashboardSnapshotService snapshotService,
string pathBase)
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
DashboardSnapshot snapshot = snapshotService.GetSnapshot();
string requestToken = tokens.RequestToken ?? string.Empty;
string body = $"""
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/logout")}" class="mb-3">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<button type="submit">Sign out</button>
</form>
<dl>
<dt>Open sessions</dt>
<dd>{snapshot.Sessions.Count}</dd>
<dt>Workers</dt>
<dd>{snapshot.Workers.Count}</dd>
<dt>Faults</dt>
<dd>{snapshot.Faults.Count}</dd>
</dl>
""";
return TypedResults.Content(RenderPage("MXAccess Gateway Dashboard", body), "text/html");
}
private static Task<ContentHttpResult> GetLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
@@ -159,14 +130,20 @@ public static class DashboardEndpointRouteBuilderExtensions
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
string body = $"""
{alert}
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
<label for="apiKey">API key</label>
<input id="apiKey" name="apiKey" type="password" autocomplete="off" />
<button type="submit">Sign in</button>
</form>
<section class="dashboard-login">
{alert}
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}" class="card login-card">
<div class="card-body">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
<div class="mb-3">
<label for="apiKey" class="form-label">API key</label>
<input id="apiKey" name="apiKey" type="password" autocomplete="off" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</div>
</form>
</section>
""";
return RenderPage("Dashboard Sign In", body);
@@ -181,12 +158,15 @@ public static class DashboardEndpointRouteBuilderExtensions
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{HtmlEncoder.Default.Encode(title)}</title>
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
</head>
<body>
<main>
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
<body class="dashboard-body">
<main class="container py-5">
<h1 class="h3 mb-4">{HtmlEncoder.Default.Encode(title)}</h1>
{body}
</main>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
</html>
""";
@@ -13,6 +13,9 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
services.AddHttpContextAccessor();
services.AddAntiforgery();
services.AddCascadingAuthenticationState();
services.AddRazorComponents()
.AddInteractiveServerComponents();
services
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
@@ -19,8 +19,10 @@ public static class GatewayApplication
WebApplication app = builder.Build();
app.UseGatewayRequestLoggingScope();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapGatewayEndpoints();
return app;
+14
View File
@@ -0,0 +1,14 @@
{
"version": "1.0",
"defaultProvider": "cdnjs",
"libraries": [
{
"library": "bootstrap@5.3.3",
"destination": "wwwroot/lib/bootstrap/",
"files": [
"css/bootstrap.min.css",
"js/bootstrap.bundle.min.js"
]
}
]
}
@@ -0,0 +1,138 @@
:root {
--mxgw-surface: #f7f8fa;
--mxgw-border: #d8dee6;
--mxgw-ink-muted: #667085;
--mxgw-accent: #146c64;
}
.dashboard-body {
background: var(--mxgw-surface);
color: #1f2933;
}
.dashboard-navbar {
min-height: 3.5rem;
}
.dashboard-content {
padding: 1.25rem;
}
.dashboard-page-header {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
}
.dashboard-page-header h1,
.section-heading h2 {
font-size: 1.35rem;
font-weight: 650;
letter-spacing: 0;
margin: 0;
}
.section-heading {
margin-bottom: .75rem;
}
.dashboard-section {
background: #fff;
border-top: 1px solid var(--mxgw-border);
margin-top: 1rem;
padding: 1rem 0 0;
}
.metric-grid {
display: grid;
gap: .75rem;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.metric-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
.metric-card {
border-color: var(--mxgw-border);
border-radius: .375rem;
box-shadow: none;
}
.metric-label {
color: var(--mxgw-ink-muted);
font-size: .78rem;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.metric-value {
color: var(--mxgw-accent);
font-size: 1.7rem;
font-weight: 700;
letter-spacing: 0;
line-height: 1.25;
overflow-wrap: anywhere;
}
.metric-detail {
color: var(--mxgw-ink-muted);
font-size: .85rem;
overflow-wrap: anywhere;
}
.dashboard-table {
--bs-table-bg: #fff;
border-color: var(--mxgw-border);
margin-bottom: 0;
}
.dashboard-table th {
color: #344054;
font-weight: 650;
white-space: nowrap;
}
.dashboard-table td {
max-width: 24rem;
overflow-wrap: anywhere;
}
.details-table th {
width: 14rem;
}
.empty-state {
background: #fff;
border: 1px dashed var(--mxgw-border);
border-radius: .375rem;
color: var(--mxgw-ink-muted);
padding: 1rem;
}
.dashboard-login {
max-width: 28rem;
}
.login-card {
border-color: var(--mxgw-border);
border-radius: .375rem;
}
@media (max-width: 700px) {
.dashboard-content {
padding: .75rem;
}
.dashboard-page-header {
align-items: flex-start;
flex-direction: column;
}
.details-table th {
width: 9rem;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -33,6 +33,37 @@ public sealed class GatewayApplicationTests
Assert.NotNull(metrics);
}
[Fact]
public void Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
{
WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/sessions");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/workers");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/events");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/settings");
Assert.Contains(endpoints, endpoint =>
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogin");
Assert.Contains(endpoints, endpoint =>
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
}
[Fact]
public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
{
WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.DoesNotContain(endpoints, endpoint =>
endpoint.RoutePattern.RawText?.StartsWith("/dashboard", StringComparison.Ordinal) == true);
Assert.DoesNotContain(endpoints, endpoint =>
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName?.StartsWith(
"Dashboard",
StringComparison.Ordinal) == true);
}
[Theory]
[InlineData(
"MxGateway:Worker:ExecutablePath",
@@ -65,4 +96,12 @@ public sealed class GatewayApplicationTests
exception.Failures,
failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
}
private static IReadOnlyList<RouteEndpoint> GetRouteEndpoints(WebApplication app)
{
return ((IEndpointRouteBuilder)app).DataSources
.SelectMany(dataSource => dataSource.Endpoints)
.OfType<RouteEndpoint>()
.ToArray();
}
}
@@ -0,0 +1,439 @@
using System.Collections.Concurrent;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
using MxGateway.Server.Grpc;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
using MxGateway.Tests.Gateway.Workers.Fakes;
namespace MxGateway.Tests.Gateway;
public sealed class GatewayEndToEndFakeWorkerSmokeTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
private const int ServerHandle = 1001;
private const int ItemHandle = 2002;
[Fact]
public async Task GatewayService_WithFakeWorker_CompletesSessionCommandEventAndClosePath()
{
ScriptedFakeWorkerProcessLauncher launcher = new();
await using GatewayServiceFixture fixture = new(launcher);
OpenSessionReply openReply = await fixture.Service.OpenSession(
new OpenSessionRequest
{
ClientSessionName = "fake-worker-e2e",
ClientCorrelationId = "open-correlation",
CommandTimeout = Duration.FromTimeSpan(TestTimeout),
},
new TestServerCallContext());
RecordingServerStreamWriter<MxEvent> eventWriter = new();
Task streamTask = fixture.Service.StreamEvents(
new StreamEventsRequest { SessionId = openReply.SessionId },
eventWriter,
new TestServerCallContext());
MxCommandReply registerReply = await fixture.Service.Invoke(
CreateRegisterRequest(openReply.SessionId),
new TestServerCallContext());
MxCommandReply addItemReply = await fixture.Service.Invoke(
CreateAddItemRequest(openReply.SessionId, registerReply.Register.ServerHandle),
new TestServerCallContext());
MxCommandReply adviseReply = await fixture.Service.Invoke(
CreateAdviseRequest(openReply.SessionId, registerReply.Register.ServerHandle, addItemReply.AddItem.ItemHandle),
new TestServerCallContext());
MxEvent dataChange = await eventWriter.WaitForFirstMessageAsync(TestTimeout);
CloseSessionReply closeReply = await fixture.Service.CloseSession(
new CloseSessionRequest
{
SessionId = openReply.SessionId,
ClientCorrelationId = "close-correlation",
},
new TestServerCallContext());
await streamTask.WaitAsync(TestTimeout);
await launcher.WorkerTask.WaitAsync(TestTimeout);
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
Assert.Equal(GatewayContractInfo.DefaultBackendName, openReply.BackendName);
Assert.Equal(ScriptedFakeWorkerProcessLauncher.ProcessId, openReply.WorkerProcessId);
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
Assert.Equal(ServerHandle, registerReply.Register.ServerHandle);
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
Assert.Equal(ItemHandle, addItemReply.AddItem.ItemHandle);
Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);
Assert.Equal(MxEventFamily.OnDataChange, dataChange.Family);
Assert.Equal(openReply.SessionId, dataChange.SessionId);
Assert.Equal(ServerHandle, dataChange.ServerHandle);
Assert.Equal(ItemHandle, dataChange.ItemHandle);
Assert.Equal("scripted-value", dataChange.Value.StringValue);
Assert.Equal(ProtocolStatusCode.Ok, closeReply.ProtocolStatus.Code);
Assert.Equal(SessionState.Closed, closeReply.FinalState);
Assert.True(launcher.Process.HasExited);
Assert.Equal(
[MxCommandKind.Register, MxCommandKind.AddItem, MxCommandKind.Advise],
launcher.CommandKinds);
}
private static MxCommandRequest CreateRegisterRequest(string sessionId)
{
return new MxCommandRequest
{
SessionId = sessionId,
ClientCorrelationId = "register-correlation",
Command = new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand { ClientName = "fake-worker-e2e-client" },
},
};
}
private static MxCommandRequest CreateAddItemRequest(
string sessionId,
int serverHandle)
{
return new MxCommandRequest
{
SessionId = sessionId,
ClientCorrelationId = "add-item-correlation",
Command = new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = serverHandle,
ItemDefinition = "Galaxy.Tag.Value",
},
},
};
}
private static MxCommandRequest CreateAdviseRequest(
string sessionId,
int serverHandle,
int itemHandle)
{
return new MxCommandRequest
{
SessionId = sessionId,
ClientCorrelationId = "advise-correlation",
Command = new MxCommand
{
Kind = MxCommandKind.Advise,
Advise = new AdviseCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
};
}
private sealed class GatewayServiceFixture : IAsyncDisposable
{
private readonly GatewayMetrics _metrics = new();
private readonly SessionRegistry _registry = new();
public GatewayServiceFixture(IWorkerProcessLauncher launcher)
{
IOptions<GatewayOptions> options = Options.Create(CreateOptions());
SessionWorkerClientFactory workerClientFactory = new(
launcher,
options,
_metrics,
NullLoggerFactory.Instance);
SessionManager sessionManager = new(
_registry,
workerClientFactory,
options,
_metrics,
logger: NullLogger<SessionManager>.Instance);
MxAccessGrpcMapper mapper = new();
EventStreamService eventStreamService = new(
sessionManager,
options,
mapper,
_metrics,
NullLogger<EventStreamService>.Instance);
Service = new MxAccessGatewayService(
sessionManager,
new GatewayRequestIdentityAccessor(),
new MxAccessGrpcRequestValidator(),
mapper,
eventStreamService,
NullLogger<MxAccessGatewayService>.Instance);
}
public MxAccessGatewayService Service { get; }
public async ValueTask DisposeAsync()
{
foreach (GatewaySession session in _registry.Snapshot())
{
await session.DisposeAsync();
}
_metrics.Dispose();
}
private static GatewayOptions CreateOptions()
{
return new GatewayOptions
{
Worker = new WorkerOptions
{
StartupTimeoutSeconds = 5,
ShutdownTimeoutSeconds = 5,
HeartbeatIntervalSeconds = 30,
HeartbeatGraceSeconds = 30,
MaxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes,
},
Sessions = new SessionOptions
{
DefaultCommandTimeoutSeconds = 5,
MaxSessions = 4,
},
Events = new EventOptions
{
QueueCapacity = 16,
},
};
}
}
private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher
{
public const int ProcessId = 4680;
private readonly ConcurrentQueue<MxCommandKind> _commandKinds = new();
public FakeWorkerProcess Process { get; } = new(ProcessId);
public IReadOnlyCollection<MxCommandKind> CommandKinds => _commandKinds.ToArray();
public Task WorkerTask { get; private set; } = Task.CompletedTask;
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
{
WorkerTask = RunWorkerAsync(request, cancellationToken);
return Task.FromResult(new WorkerProcessHandle(
Process,
new WorkerProcessCommandLine("fake-worker.exe", []),
DateTimeOffset.UtcNow));
}
private async Task RunWorkerAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken)
{
await using FakeWorkerHarness harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync(
request.SessionId,
request.Nonce,
request.PipeName,
request.ProtocolVersion,
cancellationToken: cancellationToken).ConfigureAwait(false);
await harness.CompleteStartupAsync(ProcessId, cancellationToken: cancellationToken).ConfigureAwait(false);
while (!cancellationToken.IsCancellationRequested)
{
WorkerEnvelope envelope = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerCommand)
{
await ReplyToCommandAsync(harness, envelope, cancellationToken).ConfigureAwait(false);
continue;
}
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown)
{
await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
Process.MarkExited(0);
return;
}
throw new InvalidOperationException($"Unexpected gateway envelope {envelope.BodyCase}.");
}
}
private async Task ReplyToCommandAsync(
FakeWorkerHarness harness,
WorkerEnvelope commandEnvelope,
CancellationToken cancellationToken)
{
MxCommand command = commandEnvelope.WorkerCommand.Command;
_commandKinds.Enqueue(command.Kind);
await harness.ReplyToCommandAsync(
commandEnvelope,
configureReply: reply => ConfigureReply(reply, command.Kind),
cancellationToken: cancellationToken).ConfigureAwait(false);
if (command.Kind == MxCommandKind.Advise)
{
await harness.EmitEventAsync(
MxEventFamily.OnDataChange,
cancellationToken,
mxEvent =>
{
mxEvent.ServerHandle = command.Advise.ServerHandle;
mxEvent.ItemHandle = command.Advise.ItemHandle;
mxEvent.Quality = 192;
mxEvent.Value = new MxValue
{
DataType = MxDataType.String,
StringValue = "scripted-value",
};
mxEvent.OnDataChange = new OnDataChangeEvent();
}).ConfigureAwait(false);
}
}
private static void ConfigureReply(
MxCommandReply reply,
MxCommandKind kind)
{
switch (kind)
{
case MxCommandKind.Register:
reply.Register = new RegisterReply { ServerHandle = ServerHandle };
break;
case MxCommandKind.AddItem:
reply.AddItem = new AddItemReply { ItemHandle = ItemHandle };
break;
}
}
}
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
public int Id { get; } = processId;
public bool HasExited { get; private set; }
public int? ExitCode { get; private set; }
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
HasExited = true;
ExitCode ??= 0;
return ValueTask.CompletedTask;
}
public void Kill(bool entireProcessTree)
{
MarkExited(-1);
}
public void Dispose()
{
}
public void MarkExited(int exitCode)
{
HasExited = true;
ExitCode = exitCode;
}
}
private sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
{
private readonly object _syncRoot = new();
private readonly TaskCompletionSource<T> _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<T> _messages = [];
public IReadOnlyList<T> Messages
{
get
{
lock (_syncRoot)
{
return _messages.ToArray();
}
}
}
public WriteOptions? WriteOptions { get; set; }
public Task WriteAsync(T message)
{
lock (_syncRoot)
{
_messages.Add(message);
}
_firstMessage.TrySetResult(message);
return Task.CompletedTask;
}
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout)
{
return await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
}
}
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
{
private readonly Metadata _requestHeaders = [];
private readonly Metadata _responseTrailers = [];
private readonly Dictionary<object, object> _userState = [];
private Status _status;
private WriteOptions? _writeOptions;
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
protected override string HostCore => "localhost";
protected override string PeerCore => "ipv4:127.0.0.1:5000";
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
protected override Metadata RequestHeadersCore => _requestHeaders;
protected override CancellationToken CancellationTokenCore => cancellationToken;
protected override Metadata ResponseTrailersCore => _responseTrailers;
protected override Status StatusCore
{
get => _status;
set => _status = value;
}
protected override WriteOptions? WriteOptionsCore
{
get => _writeOptions;
set => _writeOptions = value;
}
protected override AuthContext AuthContextCore { get; } = new(
string.Empty,
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
protected override IDictionary<object, object> UserStateCore => _userState;
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
{
return Task.CompletedTask;
}
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
{
throw new NotSupportedException();
}
}
}
@@ -205,6 +205,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
WorkerEnvelope commandEnvelope,
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
string statusMessage = "OK",
Action<MxCommandReply>? configureReply = null,
CancellationToken cancellationToken = default)
{
if (commandEnvelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand)
@@ -213,22 +214,25 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
}
MxCommandKind kind = commandEnvelope.WorkerCommand.Command?.Kind ?? MxCommandKind.Unspecified;
MxCommandReply reply = new()
{
SessionId = SessionId,
CorrelationId = commandEnvelope.CorrelationId,
Kind = kind,
ProtocolStatus = new ProtocolStatus
{
Code = statusCode,
Message = statusMessage,
},
};
configureReply?.Invoke(reply);
await _writer.WriteAsync(
CreateEnvelope(
commandEnvelope.CorrelationId,
envelope => envelope.WorkerCommandReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
CorrelationId = commandEnvelope.CorrelationId,
Kind = kind,
ProtocolStatus = new ProtocolStatus
{
Code = statusCode,
Message = statusMessage,
},
},
Reply = reply,
CompletedTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
}),
cancellationToken).ConfigureAwait(false);
@@ -236,21 +240,25 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
public async Task EmitEventAsync(
MxEventFamily family,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
Action<MxEvent>? configureEvent = null)
{
ulong sequence = NextWorkerSequence + 1;
MxEvent mxEvent = new()
{
SessionId = SessionId,
Family = family,
WorkerSequence = sequence,
WorkerTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
};
configureEvent?.Invoke(mxEvent);
await _writer.WriteAsync(
CreateEnvelope(
correlationId: string.Empty,
envelope => envelope.WorkerEvent = new WorkerEvent
{
Event = new MxEvent
{
SessionId = SessionId,
Family = family,
WorkerSequence = sequence,
WorkerTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
},
Event = mxEvent,
}),
cancellationToken).ConfigureAwait(false);
}