Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b86bab705 | |||
| 56886c3b4e | |||
| a3ccd5c80b | |||
| 0fd954d94c | |||
| 91f2d8dc14 | |||
| fb425da009 |
@@ -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
|
`WorkerClient` tests can use `CreateConnectedPairAsync` to create both pipe ends
|
||||||
inside the test.
|
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
|
## Focused Commands
|
||||||
|
|
||||||
Run the fake worker tests after changing gateway worker IPC, session startup, or
|
Run the fake worker tests after changing gateway worker IPC, session startup, or
|
||||||
@@ -36,6 +42,7 @@ event streaming behavior:
|
|||||||
```bash
|
```bash
|
||||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests
|
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~SessionWorkerClientFactoryFakeWorkerTests
|
||||||
|
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the gateway test project after shared gateway test infrastructure changes:
|
Run the gateway test project after shared gateway test infrastructure changes:
|
||||||
|
|||||||
@@ -34,9 +34,13 @@ SignalR circuit. Bootstrap is sufficient for a basic dashboard.
|
|||||||
|
|
||||||
## Hosting Model
|
## 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
|
```text
|
||||||
/dashboard
|
/dashboard
|
||||||
@@ -45,7 +49,7 @@ Suggested endpoint layout:
|
|||||||
/dashboard/workers
|
/dashboard/workers
|
||||||
/dashboard/events
|
/dashboard/events
|
||||||
/dashboard/settings
|
/dashboard/settings
|
||||||
/_blazor
|
/dashboard/_blazor
|
||||||
```
|
```
|
||||||
|
|
||||||
The app should redirect `/` to `/dashboard` only if the deployment wants the
|
The app should redirect `/` to `/dashboard` only if the deployment wants the
|
||||||
@@ -59,9 +63,10 @@ MxGateway.Server
|
|||||||
Components/
|
Components/
|
||||||
App.razor
|
App.razor
|
||||||
Routes.razor
|
Routes.razor
|
||||||
|
DashboardPageBase.cs
|
||||||
|
DashboardDisplay.cs
|
||||||
Layout/
|
Layout/
|
||||||
DashboardLayout.razor
|
DashboardLayout.razor
|
||||||
NavMenu.razor
|
|
||||||
Pages/
|
Pages/
|
||||||
DashboardHome.razor
|
DashboardHome.razor
|
||||||
SessionsPage.razor
|
SessionsPage.razor
|
||||||
@@ -69,26 +74,21 @@ MxGateway.Server
|
|||||||
WorkersPage.razor
|
WorkersPage.razor
|
||||||
EventsPage.razor
|
EventsPage.razor
|
||||||
SettingsPage.razor
|
SettingsPage.razor
|
||||||
Components/
|
Shared/
|
||||||
MetricCard.razor
|
MetricCard.razor
|
||||||
SessionTable.razor
|
StatusBadge.razor
|
||||||
WorkerTable.razor
|
|
||||||
EventRatePanel.razor
|
|
||||||
FaultList.razor
|
FaultList.razor
|
||||||
Services/
|
DashboardSnapshotService.cs
|
||||||
DashboardSnapshotService.cs
|
DashboardAuthorizationHandler.cs
|
||||||
DashboardUpdateHub.cs
|
DashboardAuthenticator.cs
|
||||||
DashboardAuthorization.cs
|
DashboardSnapshot.cs
|
||||||
Models/
|
DashboardSessionSummary.cs
|
||||||
DashboardSnapshot.cs
|
DashboardWorkerSummary.cs
|
||||||
SessionSummary.cs
|
DashboardMetricSummary.cs
|
||||||
WorkerSummary.cs
|
|
||||||
MetricSummary.cs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`DashboardUpdateHub` here means an internal application update service, not a
|
Blazor Server provides the SignalR circuit for UI updates. The implementation
|
||||||
separate public SignalR hub unless implementation proves one is needed. Blazor
|
does not add a separate public dashboard hub.
|
||||||
Server already uses SignalR for UI circuits.
|
|
||||||
|
|
||||||
## Dashboard Data Source
|
## Dashboard Data Source
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ gateway internals.
|
|||||||
|
|
||||||
Use Blazor Server component state updates for real-time dashboard refresh.
|
Use Blazor Server component state updates for real-time dashboard refresh.
|
||||||
|
|
||||||
Recommended pattern:
|
Implemented pattern:
|
||||||
|
|
||||||
1. Page/component subscribes to `WatchSnapshotsAsync`.
|
1. Page/component subscribes to `WatchSnapshotsAsync`.
|
||||||
2. Snapshot service emits updates from a bounded channel or timer.
|
2. Snapshot service emits updates from a bounded channel or timer.
|
||||||
@@ -147,10 +147,8 @@ Recommended pattern:
|
|||||||
|
|
||||||
Default update cadence:
|
Default update cadence:
|
||||||
|
|
||||||
- immediate update on session create/close/fault,
|
|
||||||
- immediate update on worker fault,
|
|
||||||
- periodic metrics refresh every 1 second,
|
- 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
|
Avoid pushing every MXAccess data-change event to the dashboard. Aggregate event
|
||||||
counts and rates instead.
|
counts and rates instead.
|
||||||
@@ -320,7 +318,9 @@ Suggested configuration:
|
|||||||
|
|
||||||
## Styling
|
## 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:
|
Recommended visual language:
|
||||||
|
|
||||||
@@ -361,15 +361,18 @@ Integration tests should verify:
|
|||||||
|
|
||||||
## Initial Implementation Slice
|
## Initial Implementation Slice
|
||||||
|
|
||||||
The first dashboard slice should implement:
|
The first dashboard slice implements:
|
||||||
|
|
||||||
1. Blazor Server hosting in `MxGateway.Server`.
|
1. Blazor Server hosting in `MxGateway.Server`.
|
||||||
2. Bootstrap static assets.
|
2. local Bootstrap static assets.
|
||||||
3. dashboard configuration binding.
|
3. dashboard configuration binding.
|
||||||
4. dashboard auth using API key login and HTTP-only cookie.
|
4. dashboard auth using API key login and HTTP-only cookie.
|
||||||
5. read-only `DashboardSnapshotService`.
|
5. read-only `DashboardSnapshotService`.
|
||||||
6. home page with metric cards.
|
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.
|
8. workers page with worker table.
|
||||||
9. 1-second realtime refresh through Blazor Server.
|
9. events page with aggregate counters.
|
||||||
10. redaction tests for secrets.
|
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
@@ -109,13 +109,22 @@ histograms through .NET `Meter` and a snapshot API that dashboard services can
|
|||||||
project without binding to a metrics exporter.
|
project without binding to a metrics exporter.
|
||||||
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
|
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
|
||||||
effective configuration into immutable DTOs for read-only dashboard rendering.
|
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`
|
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,
|
accepts the API key in a form body, validates the configured `admin` scope,
|
||||||
and issues an HTTP-only secure cookie for subsequent dashboard requests.
|
and issues an HTTP-only secure cookie for subsequent dashboard requests.
|
||||||
`/dashboard/logout` clears that cookie. Login and logout posts validate
|
`/dashboard/logout` clears that cookie. Login and logout posts validate
|
||||||
anti-forgery tokens, and API keys are never accepted through query strings.
|
anti-forgery tokens, and API keys are never accepted through query strings.
|
||||||
`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for
|
`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
|
### 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.Authentication;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using MxGateway.Server.Configuration;
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard.Components;
|
||||||
|
|
||||||
namespace MxGateway.Server.Dashboard;
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
@@ -22,13 +23,6 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
|
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
|
||||||
RouteGroupBuilder dashboard = endpoints.MapGroup(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(
|
dashboard.MapGet(
|
||||||
"/login",
|
"/login",
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
|
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
|
||||||
@@ -54,36 +48,13 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
.AllowAnonymous()
|
.AllowAnonymous()
|
||||||
.WithName("DashboardAccessDenied");
|
.WithName("DashboardAccessDenied");
|
||||||
|
|
||||||
|
dashboard.MapRazorComponents<App>()
|
||||||
|
.AddInteractiveServerRenderMode()
|
||||||
|
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
|
||||||
|
|
||||||
return endpoints;
|
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(
|
private static Task<ContentHttpResult> GetLoginAsync(
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
IAntiforgery antiforgery,
|
IAntiforgery antiforgery,
|
||||||
@@ -159,14 +130,20 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||||
|
|
||||||
string body = $"""
|
string body = $"""
|
||||||
{alert}
|
<section class="dashboard-login">
|
||||||
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}">
|
{alert}
|
||||||
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}" class="card login-card">
|
||||||
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
<div class="card-body">
|
||||||
<label for="apiKey">API key</label>
|
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||||
<input id="apiKey" name="apiKey" type="password" autocomplete="off" />
|
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
||||||
<button type="submit">Sign in</button>
|
<div class="mb-3">
|
||||||
</form>
|
<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);
|
return RenderPage("Dashboard Sign In", body);
|
||||||
@@ -181,12 +158,15 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||||
|
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
|
<link rel="stylesheet" href="/css/dashboard.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="dashboard-body">
|
||||||
<main>
|
<main class="container py-5">
|
||||||
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
|
<h1 class="h3 mb-4">{HtmlEncoder.Default.Encode(title)}</h1>
|
||||||
{body}
|
{body}
|
||||||
</main>
|
</main>
|
||||||
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""";
|
""";
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
services.AddAntiforgery();
|
services.AddAntiforgery();
|
||||||
|
services.AddCascadingAuthenticationState();
|
||||||
|
services.AddRazorComponents()
|
||||||
|
.AddInteractiveServerComponents();
|
||||||
services
|
services
|
||||||
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
|
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ public static class GatewayApplication
|
|||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
app.UseGatewayRequestLoggingScope();
|
app.UseGatewayRequestLoggingScope();
|
||||||
|
app.UseStaticFiles();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseAntiforgery();
|
||||||
app.MapGatewayEndpoints();
|
app.MapGatewayEndpoints();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -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);
|
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]
|
[Theory]
|
||||||
[InlineData(
|
[InlineData(
|
||||||
"MxGateway:Worker:ExecutablePath",
|
"MxGateway:Worker:ExecutablePath",
|
||||||
@@ -65,4 +96,12 @@ public sealed class GatewayApplicationTests
|
|||||||
exception.Failures,
|
exception.Failures,
|
||||||
failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
|
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,
|
WorkerEnvelope commandEnvelope,
|
||||||
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
|
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
|
||||||
string statusMessage = "OK",
|
string statusMessage = "OK",
|
||||||
|
Action<MxCommandReply>? configureReply = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (commandEnvelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand)
|
if (commandEnvelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand)
|
||||||
@@ -213,22 +214,25 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
MxCommandKind kind = commandEnvelope.WorkerCommand.Command?.Kind ?? MxCommandKind.Unspecified;
|
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(
|
await _writer.WriteAsync(
|
||||||
CreateEnvelope(
|
CreateEnvelope(
|
||||||
commandEnvelope.CorrelationId,
|
commandEnvelope.CorrelationId,
|
||||||
envelope => envelope.WorkerCommandReply = new WorkerCommandReply
|
envelope => envelope.WorkerCommandReply = new WorkerCommandReply
|
||||||
{
|
{
|
||||||
Reply = new MxCommandReply
|
Reply = reply,
|
||||||
{
|
|
||||||
SessionId = SessionId,
|
|
||||||
CorrelationId = commandEnvelope.CorrelationId,
|
|
||||||
Kind = kind,
|
|
||||||
ProtocolStatus = new ProtocolStatus
|
|
||||||
{
|
|
||||||
Code = statusCode,
|
|
||||||
Message = statusMessage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CompletedTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
CompletedTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||||
}),
|
}),
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
@@ -236,21 +240,25 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task EmitEventAsync(
|
public async Task EmitEventAsync(
|
||||||
MxEventFamily family,
|
MxEventFamily family,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default,
|
||||||
|
Action<MxEvent>? configureEvent = null)
|
||||||
{
|
{
|
||||||
ulong sequence = NextWorkerSequence + 1;
|
ulong sequence = NextWorkerSequence + 1;
|
||||||
|
MxEvent mxEvent = new()
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
Family = family,
|
||||||
|
WorkerSequence = sequence,
|
||||||
|
WorkerTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||||
|
};
|
||||||
|
configureEvent?.Invoke(mxEvent);
|
||||||
|
|
||||||
await _writer.WriteAsync(
|
await _writer.WriteAsync(
|
||||||
CreateEnvelope(
|
CreateEnvelope(
|
||||||
correlationId: string.Empty,
|
correlationId: string.Empty,
|
||||||
envelope => envelope.WorkerEvent = new WorkerEvent
|
envelope => envelope.WorkerEvent = new WorkerEvent
|
||||||
{
|
{
|
||||||
Event = new MxEvent
|
Event = mxEvent,
|
||||||
{
|
|
||||||
SessionId = SessionId,
|
|
||||||
Family = family,
|
|
||||||
WorkerSequence = sequence,
|
|
||||||
WorkerTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user