rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
@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/theme.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,62 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||
|
||||
public static class DashboardDisplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Formats a nullable date and time value for display.
|
||||
/// </summary>
|
||||
/// <param name="value">The date and time to format.</param>
|
||||
/// <returns>Formatted date and time string or "-" if null.</returns>
|
||||
public static string DateTime(DateTimeOffset? value)
|
||||
{
|
||||
return value.HasValue
|
||||
? value.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a time span duration for display.
|
||||
/// </summary>
|
||||
/// <param name="value">The duration to format.</param>
|
||||
/// <returns>Formatted duration string.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a nullable text value for display.
|
||||
/// </summary>
|
||||
/// <param name="value">The text to format.</param>
|
||||
/// <returns>Formatted text or "-" if null or empty.</returns>
|
||||
public static string Text(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? "-" : value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a long count value for display with thousands separator.
|
||||
/// </summary>
|
||||
/// <param name="value">The count to format.</param>
|
||||
/// <returns>Formatted count string.</returns>
|
||||
public static string Count(long value)
|
||||
{
|
||||
return value.ToString("N0", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a metric value from a snapshot by name and optional dimension.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Dashboard snapshot.</param>
|
||||
/// <param name="name">Metric name.</param>
|
||||
/// <param name="dimension">Optional metric dimension.</param>
|
||||
/// <returns>Metric value or zero if not found.</returns>
|
||||
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,62 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for Blazor dashboard pages that watch gateway metrics snapshots.
|
||||
/// </summary>
|
||||
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _disposeCancellation = new();
|
||||
private Task? _watchTask;
|
||||
|
||||
/// <summary>
|
||||
/// Service that provides gateway metric snapshots.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The most recent gateway metric snapshot, updated as it changes.
|
||||
/// </summary>
|
||||
protected DashboardSnapshot? Snapshot { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_watchTask = WatchSnapshotsAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _disposeCancellation.CancelAsync().ConfigureAwait(false);
|
||||
if (_watchTask is not null)
|
||||
{
|
||||
await _watchTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_disposeCancellation.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches snapshot changes and triggers component refresh.
|
||||
/// </summary>
|
||||
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,50 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<div class="dashboard-shell">
|
||||
<header class="app-bar">
|
||||
<a class="brand" href=""><span class="mark">▮</span> MXAccess Gateway</a>
|
||||
<nav class="app-nav">
|
||||
<NavLink href="" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
<NavLink href="sessions">Sessions</NavLink>
|
||||
<NavLink href="workers">Workers</NavLink>
|
||||
<NavLink href="events">Events</NavLink>
|
||||
<NavLink href="galaxy">Galaxy</NavLink>
|
||||
<NavLink href="browse">Browse</NavLink>
|
||||
<NavLink href="alarms">Alarms</NavLink>
|
||||
<NavLink href="apikeys">API Keys</NavLink>
|
||||
<NavLink href="settings">Settings</NavLink>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="app-user">
|
||||
<span class="meta">@authState.User.Identity?.Name</span>
|
||||
<form method="post" action="@DashboardPath("/logout")">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</header>
|
||||
<main class="page">
|
||||
@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,286 @@
|
||||
@page "/alarms"
|
||||
@page "/dashboard/alarms"
|
||||
@implements IAsyncDisposable
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<PageTitle>Dashboard Alarms</PageTitle>
|
||||
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Alarms</h1>
|
||||
<div class="text-secondary">@HeaderLine()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!GatewayOptions.Value.Alarms.Enabled)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
Alarm auto-subscribe is disabled (<code>MxGateway:Alarms:Enabled</code> is false). The
|
||||
dashboard session is not subscribed to any alarm provider, so this list will stay empty.
|
||||
Enable alarms in configuration and restart the gateway.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_queryError))
|
||||
{
|
||||
<div class="alert alert-danger">Alarm query failed: @_queryError</div>
|
||||
}
|
||||
|
||||
<section class="metric-grid compact">
|
||||
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
|
||||
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
|
||||
<MetricCard Label="Total Active" Value="@_alarms.Count.ToString("N0")" />
|
||||
<MetricCard Label="Showing" Value="@FilteredAlarms().Count.ToString("N0")" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Filters</h2>
|
||||
</div>
|
||||
<div class="alarm-filters">
|
||||
<label class="alarm-filter-check">
|
||||
<input type="checkbox" @bind="_showActive" />
|
||||
<span>Active (unacked)</span>
|
||||
</label>
|
||||
<label class="alarm-filter-check">
|
||||
<input type="checkbox" @bind="_showAcked" />
|
||||
<span>Acknowledged</span>
|
||||
</label>
|
||||
<div class="alarm-filter-field">
|
||||
<label class="form-label" for="alarm-area">Area</label>
|
||||
<select id="alarm-area" class="form-select form-select-sm" @bind="_areaFilter">
|
||||
<option value="">All areas</option>
|
||||
@foreach (string area in Areas())
|
||||
{
|
||||
<option value="@area">@area</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="alarm-filter-field">
|
||||
<label class="form-label" for="alarm-sev-min">Min severity</label>
|
||||
<input id="alarm-sev-min" type="number" class="form-control form-control-sm" @bind="_minSeverity" />
|
||||
</div>
|
||||
<div class="alarm-filter-field">
|
||||
<label class="form-label" for="alarm-sev-max">Max severity</label>
|
||||
<input id="alarm-sev-max" type="number" class="form-control form-control-sm" @bind="_maxSeverity" />
|
||||
</div>
|
||||
<div class="alarm-filter-field alarm-filter-grow">
|
||||
<label class="form-label" for="alarm-search">Search</label>
|
||||
<input id="alarm-search" class="form-control form-control-sm"
|
||||
placeholder="Reference, source or description…"
|
||||
@bind="_search" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Active Alarms</h2>
|
||||
</div>
|
||||
@{
|
||||
IReadOnlyList<DashboardActiveAlarm> rows = FilteredAlarms();
|
||||
}
|
||||
@if (rows.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
@if (_alarms.Count == 0)
|
||||
{
|
||||
<span>No alarms are currently Active or ActiveAcked.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>No alarms match the current filters.</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">State</th>
|
||||
<th scope="col">Severity</th>
|
||||
<th scope="col">Alarm Reference</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Area</th>
|
||||
<th scope="col">Last Transition</th>
|
||||
<th scope="col">Operator</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardActiveAlarm alarm in rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="alarm-state @StateClass(alarm.State)">@StateText(alarm.State)</span></td>
|
||||
<td class="alarm-severity">@alarm.Severity</td>
|
||||
<td>
|
||||
<code>@alarm.Reference</code>
|
||||
@if (!string.IsNullOrWhiteSpace(alarm.Description))
|
||||
{
|
||||
<div class="alarm-desc">@alarm.Description</div>
|
||||
}
|
||||
</td>
|
||||
<td>@DashboardDisplay.Text(alarm.Source)</td>
|
||||
<td>@DashboardDisplay.Text(alarm.AlarmType)</td>
|
||||
<td>@DashboardDisplay.Text(alarm.Area)</td>
|
||||
<td>@(alarm.LastTransition is { } ts ? DashboardDisplay.DateTime(ts) : "-")</td>
|
||||
<td>
|
||||
@DashboardDisplay.Text(alarm.OperatorUser)
|
||||
@if (!string.IsNullOrWhiteSpace(alarm.OperatorComment))
|
||||
{
|
||||
<div class="alarm-desc">@alarm.OperatorComment</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
<div class="browse-search-note">
|
||||
Cleared alarms are not retained — this list reflects only alarms currently Active or
|
||||
ActiveAcked, refreshed every 3 seconds.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private readonly List<DashboardActiveAlarm> _alarms = [];
|
||||
private string? _queryError;
|
||||
private int? _workerPid;
|
||||
private DateTimeOffset? _lastRefresh;
|
||||
private int _unackedCount;
|
||||
private int _ackedCount;
|
||||
|
||||
private bool _showActive = true;
|
||||
private bool _showAcked;
|
||||
private string _areaFilter = string.Empty;
|
||||
private int _minSeverity;
|
||||
private int _maxSeverity = 1000;
|
||||
private string _search = string.Empty;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _pollTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_pollTask = PollLoopAsync();
|
||||
}
|
||||
|
||||
private string HeaderLine()
|
||||
{
|
||||
string refreshed = _lastRefresh is { } at
|
||||
? $"refreshed {DashboardDisplay.DateTime(at)}"
|
||||
: "awaiting first refresh";
|
||||
return _workerPid is int pid ? $"{refreshed} · worker pid {pid}" : refreshed;
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> Areas()
|
||||
{
|
||||
return _alarms
|
||||
.Select(alarm => alarm.Area)
|
||||
.Where(area => !string.IsNullOrWhiteSpace(area))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(area => area, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IReadOnlyList<DashboardActiveAlarm> FilteredAlarms()
|
||||
{
|
||||
string query = _search.Trim();
|
||||
return _alarms
|
||||
.Where(MatchesState)
|
||||
.Where(alarm => _areaFilter.Length == 0
|
||||
|| string.Equals(alarm.Area, _areaFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(alarm => alarm.Severity >= _minSeverity && alarm.Severity <= _maxSeverity)
|
||||
.Where(alarm => query.Length == 0
|
||||
|| alarm.Reference.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| alarm.Source.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| alarm.Description.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(alarm => alarm.Severity)
|
||||
.ThenByDescending(alarm => alarm.LastTransition ?? DateTimeOffset.MinValue)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private bool MatchesState(DashboardActiveAlarm alarm)
|
||||
{
|
||||
return alarm.State switch
|
||||
{
|
||||
AlarmConditionState.Active => _showActive,
|
||||
AlarmConditionState.ActiveAcked => _showAcked,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
private static string StateText(AlarmConditionState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AlarmConditionState.Active => "Active",
|
||||
AlarmConditionState.ActiveAcked => "Acked",
|
||||
AlarmConditionState.Inactive => "Inactive",
|
||||
_ => "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
private static string StateClass(AlarmConditionState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AlarmConditionState.Active => "alarm-state-active",
|
||||
AlarmConditionState.ActiveAcked => "alarm-state-acked",
|
||||
_ => "alarm-state-other",
|
||||
};
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
|
||||
using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
|
||||
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
|
||||
{
|
||||
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshAlarmsAsync()
|
||||
{
|
||||
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
|
||||
_queryError = result.Error;
|
||||
_workerPid = result.WorkerProcessId;
|
||||
_lastRefresh = DateTimeOffset.UtcNow;
|
||||
_alarms.Clear();
|
||||
_alarms.AddRange(result.Alarms);
|
||||
_unackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.Active);
|
||||
_ackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.ActiveAcked);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
if (_pollTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _pollTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
@page "/apikeys"
|
||||
@page "/dashboard/apikeys"
|
||||
@inherits DashboardPageBase
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject IDashboardApiKeyManagementService ApiKeyManagementService
|
||||
|
||||
<PageTitle>Dashboard API Keys</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading API keys.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>API Keys</h1>
|
||||
<div class="text-secondary">@Snapshot.ApiKeys.Count key rows</div>
|
||||
</div>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="OpenCreateDialog">
|
||||
Create API Key
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
@if (!string.IsNullOrWhiteSpace(ResultMessage))
|
||||
{
|
||||
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
|
||||
@ResultMessage
|
||||
@if (!string.IsNullOrWhiteSpace(LastGeneratedApiKey))
|
||||
{
|
||||
<div class="mt-2">
|
||||
<code class="one-time-secret">@LastGeneratedApiKey</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsCreateDialogOpen)
|
||||
{
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal fade show api-key-create-modal" role="dialog" aria-modal="true" aria-labelledby="createApiKeyTitle">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<EditForm Model="@CreateModel" OnSubmit="@CreateApiKeyAsync">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title h5" id="createApiKeyTitle">Create API Key</h2>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseCreateDialog"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="api-key-management-grid">
|
||||
<div class="mb-3">
|
||||
<label for="keyId" class="form-label">Key ID</label>
|
||||
<input id="keyId" class="form-control" @bind="CreateModel.KeyId" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="displayName" class="form-label">Display Name</label>
|
||||
<input id="displayName" class="form-control" @bind="CreateModel.DisplayName" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="form-label">Scopes</legend>
|
||||
<div class="scope-grid">
|
||||
@foreach (string scope in AvailableScopes)
|
||||
{
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
checked="@IsScopeSelected(scope)"
|
||||
@onchange="eventArgs => SetScope(scope, eventArgs)" />
|
||||
<span class="form-check-label">@scope</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="api-key-management-grid">
|
||||
<div class="mb-3">
|
||||
<label for="readSubtrees" class="form-label">Read subtrees</label>
|
||||
<textarea id="readSubtrees" class="form-control" rows="2" @bind="CreateModel.ReadSubtrees" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="writeSubtrees" class="form-label">Write subtrees</label>
|
||||
<textarea id="writeSubtrees" class="form-control" rows="2" @bind="CreateModel.WriteSubtrees" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="readTagGlobs" class="form-label">Read tag globs</label>
|
||||
<textarea id="readTagGlobs" class="form-control" rows="2" @bind="CreateModel.ReadTagGlobs" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="writeTagGlobs" class="form-label">Write tag globs</label>
|
||||
<textarea id="writeTagGlobs" class="form-control" rows="2" @bind="CreateModel.WriteTagGlobs" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="browseSubtrees" class="form-label">Browse subtrees</label>
|
||||
<textarea id="browseSubtrees" class="form-control" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="maxWriteClassification" class="form-label">Max write classification</label>
|
||||
<input id="maxWriteClassification" class="form-control" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadAlarmOnly" />
|
||||
<span class="form-check-label">Read alarm only</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadHistorizedOnly" />
|
||||
<span class="form-check-label">Read historized only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled="@IsBusy">Create Key</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.ApiKeys.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No API keys are available for display.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Display Name</th>
|
||||
<th scope="col">Scopes</th>
|
||||
<th scope="col">Constraints</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Last Used</th>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<th scope="col">Actions</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardApiKeySummary key in Snapshot.ApiKeys)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@key.KeyId</code></td>
|
||||
<td><StatusBadge Text="@(key.RevokedUtc is null ? "Active" : "Revoked")" /></td>
|
||||
<td>@DashboardDisplay.Text(key.DisplayName)</td>
|
||||
<td>@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))</td>
|
||||
<td>@DashboardDisplay.Text(ConstraintText(key.Constraints))</td>
|
||||
<td>@DashboardDisplay.DateTime(key.CreatedUtc)</td>
|
||||
<td>@DashboardDisplay.DateTime(key.LastUsedUtc)</td>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
|
||||
@if (key.RevokedUtc is null)
|
||||
{
|
||||
@* Rotate clears revoked_utc, which would silently reactivate a
|
||||
deliberately revoked key. Only offer it for active keys so a
|
||||
revoked key is not un-revoked as a side effect of rotation. *@
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => RotateApiKeyAsync(key.KeyId)">
|
||||
Rotate
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => RevokeApiKeyAsync(key.KeyId)">
|
||||
Revoke
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">No actions</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly string[] AvailableScopes =
|
||||
[
|
||||
GatewayScopes.SessionOpen,
|
||||
GatewayScopes.SessionClose,
|
||||
GatewayScopes.InvokeRead,
|
||||
GatewayScopes.InvokeWrite,
|
||||
GatewayScopes.InvokeSecure,
|
||||
GatewayScopes.EventsRead,
|
||||
GatewayScopes.MetadataRead,
|
||||
GatewayScopes.Admin
|
||||
];
|
||||
|
||||
private ApiKeyCreateModel CreateModel { get; } = new();
|
||||
|
||||
private bool CanManageApiKeys { get; set; }
|
||||
|
||||
private bool IsBusy { get; set; }
|
||||
|
||||
private bool IsCreateDialogOpen { get; set; }
|
||||
|
||||
private string? ResultMessage { get; set; }
|
||||
|
||||
private bool LastOperationSucceeded { get; set; }
|
||||
|
||||
private string? LastGeneratedApiKey { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
|
||||
.ConfigureAwait(false);
|
||||
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task CreateApiKeyAsync()
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? validationMessage))
|
||||
{
|
||||
SetResult(DashboardApiKeyManagementResult.Fail(validationMessage ?? "API key request is invalid."));
|
||||
return;
|
||||
}
|
||||
|
||||
await RunManagementActionAsync(user => ApiKeyManagementService.CreateAsync(
|
||||
user,
|
||||
request,
|
||||
CancellationToken.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RevokeApiKeyAsync(string keyId)
|
||||
{
|
||||
await RunManagementActionAsync(user => ApiKeyManagementService.RevokeAsync(
|
||||
user,
|
||||
keyId,
|
||||
CancellationToken.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RotateApiKeyAsync(string keyId)
|
||||
{
|
||||
await RunManagementActionAsync(user => ApiKeyManagementService.RotateAsync(
|
||||
user,
|
||||
keyId,
|
||||
CancellationToken.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RunManagementActionAsync(
|
||||
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> action)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
|
||||
.ConfigureAwait(false);
|
||||
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
|
||||
DashboardApiKeyManagementResult result = await action(authenticationState.User).ConfigureAwait(false);
|
||||
SetResult(result);
|
||||
if (result.Succeeded && result.ApiKey is not null)
|
||||
{
|
||||
CreateModel.Reset();
|
||||
IsCreateDialogOpen = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetResult(DashboardApiKeyManagementResult result)
|
||||
{
|
||||
LastOperationSucceeded = result.Succeeded;
|
||||
ResultMessage = result.Message;
|
||||
LastGeneratedApiKey = result.ApiKey;
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
IsCreateDialogOpen = true;
|
||||
}
|
||||
|
||||
private void CloseCreateDialog()
|
||||
{
|
||||
if (!IsBusy)
|
||||
{
|
||||
IsCreateDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryBuildCreateRequest(
|
||||
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request,
|
||||
out string? validationMessage)
|
||||
{
|
||||
request = null;
|
||||
validationMessage = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
|
||||
&& !int.TryParse(
|
||||
CreateModel.MaxWriteClassification,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int _))
|
||||
{
|
||||
validationMessage = "Max write classification must be an integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
int? maxWriteClassification = string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
|
||||
? null
|
||||
: int.Parse(
|
||||
CreateModel.MaxWriteClassification,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
request = new DashboardApiKeyManagementRequest(
|
||||
KeyId: CreateModel.KeyId,
|
||||
DisplayName: CreateModel.DisplayName,
|
||||
Scopes: CreateModel.SelectedScopes,
|
||||
Constraints: new ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyConstraints(
|
||||
ReadSubtrees: ParseList(CreateModel.ReadSubtrees),
|
||||
WriteSubtrees: ParseList(CreateModel.WriteSubtrees),
|
||||
ReadTagGlobs: ParseList(CreateModel.ReadTagGlobs),
|
||||
WriteTagGlobs: ParseList(CreateModel.WriteTagGlobs),
|
||||
MaxWriteClassification: maxWriteClassification,
|
||||
BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees),
|
||||
ReadAlarmOnly: CreateModel.ReadAlarmOnly,
|
||||
ReadHistorizedOnly: CreateModel.ReadHistorizedOnly));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsScopeSelected(string scope)
|
||||
{
|
||||
return CreateModel.SelectedScopes.Contains(scope);
|
||||
}
|
||||
|
||||
private void SetScope(string scope, ChangeEventArgs eventArgs)
|
||||
{
|
||||
bool selected = eventArgs.Value is bool value && value;
|
||||
if (selected)
|
||||
{
|
||||
CreateModel.SelectedScopes.Add(scope);
|
||||
}
|
||||
else
|
||||
{
|
||||
CreateModel.SelectedScopes.Remove(scope);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConstraintText(ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyConstraints constraints)
|
||||
{
|
||||
if (constraints.IsEmpty)
|
||||
{
|
||||
return "unconstrained";
|
||||
}
|
||||
|
||||
List<string> parts = [];
|
||||
AddList(parts, "read_subtrees", constraints.ReadSubtrees);
|
||||
AddList(parts, "write_subtrees", constraints.WriteSubtrees);
|
||||
AddList(parts, "read_tag_globs", constraints.ReadTagGlobs);
|
||||
AddList(parts, "write_tag_globs", constraints.WriteTagGlobs);
|
||||
AddList(parts, "browse_subtrees", constraints.BrowseSubtrees);
|
||||
if (constraints.MaxWriteClassification is { } max)
|
||||
{
|
||||
parts.Add($"max_write_classification={max}");
|
||||
}
|
||||
|
||||
if (constraints.ReadAlarmOnly)
|
||||
{
|
||||
parts.Add("read_alarm_only");
|
||||
}
|
||||
|
||||
if (constraints.ReadHistorizedOnly)
|
||||
{
|
||||
parts.Add("read_historized_only");
|
||||
}
|
||||
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static void AddList(List<string> parts, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values.Count > 0)
|
||||
{
|
||||
parts.Add($"{name}=[{string.Join(", ", values)}]");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseList(string? value)
|
||||
{
|
||||
return (value ?? string.Empty)
|
||||
.Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private sealed class ApiKeyCreateModel
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public HashSet<string> SelectedScopes { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public string ReadSubtrees { get; set; } = string.Empty;
|
||||
|
||||
public string WriteSubtrees { get; set; } = string.Empty;
|
||||
|
||||
public string ReadTagGlobs { get; set; } = string.Empty;
|
||||
|
||||
public string WriteTagGlobs { get; set; } = string.Empty;
|
||||
|
||||
public string BrowseSubtrees { get; set; } = string.Empty;
|
||||
|
||||
public string MaxWriteClassification { get; set; } = string.Empty;
|
||||
|
||||
public bool ReadAlarmOnly { get; set; }
|
||||
|
||||
public bool ReadHistorizedOnly { get; set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
KeyId = string.Empty;
|
||||
DisplayName = string.Empty;
|
||||
SelectedScopes.Clear();
|
||||
ReadSubtrees = string.Empty;
|
||||
WriteSubtrees = string.Empty;
|
||||
ReadTagGlobs = string.Empty;
|
||||
WriteTagGlobs = string.Empty;
|
||||
BrowseSubtrees = string.Empty;
|
||||
MaxWriteClassification = string.Empty;
|
||||
ReadAlarmOnly = false;
|
||||
ReadHistorizedOnly = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
@page "/browse"
|
||||
@page "/dashboard/browse"
|
||||
@implements IAsyncDisposable
|
||||
@inject IGalaxyHierarchyCache GalaxyCache
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
|
||||
@using ZB.MOM.WW.MxGateway.Server.Galaxy
|
||||
|
||||
<PageTitle>Dashboard Browse</PageTitle>
|
||||
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Browse</h1>
|
||||
<div class="text-secondary">@HeaderLine()</div>
|
||||
</div>
|
||||
<StatusBadge Text="@GalaxyCache.Current.Status.ToString()" />
|
||||
</div>
|
||||
|
||||
<div class="browse-layout">
|
||||
<section class="dashboard-section browse-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Galaxy Hierarchy</h2>
|
||||
</div>
|
||||
<input class="form-control form-control-sm browse-search"
|
||||
placeholder="Filter attributes by name or reference…"
|
||||
@bind="Search"
|
||||
@bind:event="oninput" />
|
||||
|
||||
@if (_roots.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
No Galaxy hierarchy is cached yet. The hierarchy refreshes from the
|
||||
Galaxy Repository in the background — check the Galaxy tab for status.
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(Search))
|
||||
{
|
||||
@if (_searchMatches.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No attributes match “@Search”.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="browse-tree browse-search-results">
|
||||
@foreach (GalaxyAttribute hit in _searchMatches)
|
||||
{
|
||||
GalaxyAttribute row = hit;
|
||||
<div class="tree-attr"
|
||||
title="@row.FullTagReference"
|
||||
@oncontextmenu:preventDefault="true"
|
||||
@oncontextmenu="@(args => ShowMenu(args, row))"
|
||||
@ondblclick="@(() => AddTagAsync(row.FullTagReference))">
|
||||
<span class="attr-icon">·</span>
|
||||
<span class="attr-name">@row.FullTagReference</span>
|
||||
<span class="attr-type">@FormatType(row)</span>
|
||||
@if (row.IsAlarm)
|
||||
{
|
||||
<span class="attr-flag attr-flag-alarm">alarm</span>
|
||||
}
|
||||
@if (row.IsHistorized)
|
||||
{
|
||||
<span class="attr-flag attr-flag-hist">hist</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (_searchMatches.Count >= SearchResultLimit)
|
||||
{
|
||||
<div class="browse-search-note">Showing the first @SearchResultLimit matches — refine the filter.</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="browse-tree">
|
||||
@foreach (DashboardBrowseNode root in _roots)
|
||||
{
|
||||
<BrowseTreeNodeView Node="root"
|
||||
OnAddTag="AddTagAsync"
|
||||
OnTagContextMenu="OnTagContextMenu" />
|
||||
}
|
||||
</div>
|
||||
<div class="browse-search-note">Double-click a tag, or right-click for the menu.</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section browse-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Subscription Panel</h2>
|
||||
</div>
|
||||
<div class="sub-panel-meta">
|
||||
@if (_subscribed.Count > 0)
|
||||
{
|
||||
<span>@_subscribed.Count subscribed</span>
|
||||
<span>·</span>
|
||||
<span>refresh 2s</span>
|
||||
@if (_workerPid is int pid)
|
||||
{
|
||||
<span>·</span>
|
||||
<span>worker pid @pid</span>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm sub-clear" @onclick="ClearAll">Clear all</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_readError))
|
||||
{
|
||||
<div class="alert alert-danger">Live read failed: @_readError</div>
|
||||
}
|
||||
|
||||
@if (_subscribed.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
No tags subscribed. Right-click a tag in the hierarchy and choose
|
||||
<strong>Add to subscription panel</strong> (or double-click it) to watch its
|
||||
live value, quality and source timestamp here.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table sub-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Tag</th>
|
||||
<th scope="col">Value</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Quality</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col" class="sub-actions-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (string tag in _subscribed)
|
||||
{
|
||||
string key = tag;
|
||||
DashboardTagValue? value = _values.GetValueOrDefault(key);
|
||||
<tr>
|
||||
<td><code>@key</code></td>
|
||||
<td class="sub-value">@(value?.ValueText ?? "…")</td>
|
||||
<td>@(value?.DataType ?? "-")</td>
|
||||
<td>
|
||||
@if (value is null)
|
||||
{
|
||||
<span class="text-secondary">…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="quality-chip @(value.QualityGood ? "quality-good" : "quality-bad")">
|
||||
@value.Quality
|
||||
</span>
|
||||
@if (!string.IsNullOrWhiteSpace(value.Error))
|
||||
{
|
||||
<span class="sub-error" title="@value.Error">!</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td title="@TimestampTooltip(value)">@TimestampText(key, value)</td>
|
||||
<td class="sub-actions-col">
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
@onclick="@(() => RemoveTag(key))">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@if (_menuVisible)
|
||||
{
|
||||
<div class="context-menu-overlay"
|
||||
@onclick="HideMenu"
|
||||
@oncontextmenu:preventDefault="true"
|
||||
@oncontextmenu="HideMenu"></div>
|
||||
<div class="context-menu" style="left:@(_menuX)px; top:@(_menuY)px;">
|
||||
<div class="context-menu-head">@(_menuAttribute?.AttributeName)</div>
|
||||
<button type="button" class="context-menu-item" @onclick="AddMenuTagAsync">
|
||||
Add to subscription panel
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int SearchResultLimit = 300;
|
||||
|
||||
private IReadOnlyList<DashboardBrowseNode> _roots = [];
|
||||
private string _search = string.Empty;
|
||||
private IReadOnlyList<GalaxyAttribute> _searchMatches = [];
|
||||
private readonly List<string> _subscribed = [];
|
||||
private readonly Dictionary<string, DashboardTagValue> _values = new(StringComparer.Ordinal);
|
||||
// Per-tag bookkeeping for the Updated column: the signature of the value
|
||||
// last seen, and when that value/quality was first observed. Lets the
|
||||
// column move only on a real change, not on every 2s poll.
|
||||
private readonly Dictionary<string, string> _valueSignature = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, DateTimeOffset> _observedChangeAt = new(StringComparer.Ordinal);
|
||||
private string? _readError;
|
||||
private int? _workerPid;
|
||||
|
||||
private bool _menuVisible;
|
||||
private int _menuX;
|
||||
private int _menuY;
|
||||
private GalaxyAttribute? _menuAttribute;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _pollTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_roots = DashboardBrowseTreeBuilder.Build(GalaxyCache.Current.Objects);
|
||||
_pollTask = PollLoopAsync();
|
||||
}
|
||||
|
||||
private string HeaderLine()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyCache.Current;
|
||||
return $"{entry.ObjectCount:N0} objects · {entry.AttributeCount:N0} attributes · "
|
||||
+ $"{entry.AlarmAttributeCount:N0} alarm attributes";
|
||||
}
|
||||
|
||||
private string Search
|
||||
{
|
||||
get => _search;
|
||||
set
|
||||
{
|
||||
_search = value ?? string.Empty;
|
||||
_searchMatches = ComputeSearch(_search);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<GalaxyAttribute> ComputeSearch(string rawQuery)
|
||||
{
|
||||
string query = rawQuery.Trim();
|
||||
if (query.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<GalaxyAttribute> matches = [];
|
||||
foreach (GalaxyObject galaxyObject in GalaxyCache.Current.Objects)
|
||||
{
|
||||
foreach (GalaxyAttribute attr in galaxyObject.Attributes)
|
||||
{
|
||||
if (attr.FullTagReference.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| attr.AttributeName.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matches.Add(attr);
|
||||
if (matches.Count >= SearchResultLimit)
|
||||
{
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
private static string FormatType(GalaxyAttribute attr)
|
||||
{
|
||||
string baseType = string.IsNullOrWhiteSpace(attr.DataTypeName) ? "type?" : attr.DataTypeName;
|
||||
if (!attr.IsArray)
|
||||
{
|
||||
return baseType;
|
||||
}
|
||||
|
||||
return attr.ArrayDimensionPresent ? $"{baseType}[{attr.ArrayDimension}]" : $"{baseType}[]";
|
||||
}
|
||||
|
||||
private Task OnTagContextMenu((MouseEventArgs Event, GalaxyAttribute Attribute) args)
|
||||
{
|
||||
ShowMenu(args.Event, args.Attribute);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ShowMenu(MouseEventArgs args, GalaxyAttribute attr)
|
||||
{
|
||||
_menuAttribute = attr;
|
||||
_menuX = (int)args.ClientX;
|
||||
_menuY = (int)args.ClientY;
|
||||
_menuVisible = true;
|
||||
}
|
||||
|
||||
private void HideMenu()
|
||||
{
|
||||
_menuVisible = false;
|
||||
_menuAttribute = null;
|
||||
}
|
||||
|
||||
private async Task AddMenuTagAsync()
|
||||
{
|
||||
GalaxyAttribute? attr = _menuAttribute;
|
||||
HideMenu();
|
||||
if (attr is not null)
|
||||
{
|
||||
await AddTagAsync(attr.FullTagReference);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddTagAsync(string fullReference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullReference)
|
||||
|| _subscribed.Contains(fullReference, StringComparer.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_subscribed.Add(fullReference);
|
||||
await RefreshValuesAsync();
|
||||
}
|
||||
|
||||
private void RemoveTag(string tag)
|
||||
{
|
||||
_subscribed.Remove(tag);
|
||||
_values.Remove(tag);
|
||||
_valueSignature.Remove(tag);
|
||||
_observedChangeAt.Remove(tag);
|
||||
}
|
||||
|
||||
private void ClearAll()
|
||||
{
|
||||
_subscribed.Clear();
|
||||
_values.Clear();
|
||||
_valueSignature.Clear();
|
||||
_observedChangeAt.Clear();
|
||||
_readError = null;
|
||||
}
|
||||
|
||||
// The MXAccess source timestamp when the worker supplies one, otherwise the
|
||||
// time the dashboard first observed the current value/quality.
|
||||
private string TimestampText(string tag, DashboardTagValue? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return "…";
|
||||
}
|
||||
|
||||
if (value.SourceTimestamp is { } source)
|
||||
{
|
||||
return DashboardDisplay.DateTime(source);
|
||||
}
|
||||
|
||||
return _observedChangeAt.TryGetValue(tag, out DateTimeOffset observed)
|
||||
? DashboardDisplay.DateTime(observed)
|
||||
: "-";
|
||||
}
|
||||
|
||||
private static string TimestampTooltip(DashboardTagValue? value)
|
||||
{
|
||||
return value?.SourceTimestamp is not null
|
||||
? "MXAccess source timestamp."
|
||||
: "When the dashboard first observed this value — MXAccess did not supply a source timestamp for this tag.";
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using PeriodicTimer timer = new(TimeSpan.FromSeconds(2));
|
||||
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
|
||||
{
|
||||
await InvokeAsync(RefreshValuesAsync).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshValuesAsync()
|
||||
{
|
||||
if (_subscribed.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] tags = [.. _subscribed];
|
||||
DashboardLiveReadResult result = await LiveData.ReadAsync(tags, _cts.Token);
|
||||
_readError = result.Error;
|
||||
_workerPid = result.WorkerProcessId;
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
foreach (DashboardTagValue value in result.Values)
|
||||
{
|
||||
// Stamp the observed-change time only when the value/quality
|
||||
// signature actually changes, so the Updated column does not
|
||||
// tick on every poll for a static tag.
|
||||
string signature = $"{value.ValueText}{value.Quality}{value.Ok}";
|
||||
if (!_valueSignature.TryGetValue(value.TagAddress, out string? previous)
|
||||
|| previous != signature)
|
||||
{
|
||||
_valueSignature[value.TagAddress] = signature;
|
||||
_observedChangeAt[value.TagAddress] = now;
|
||||
}
|
||||
|
||||
_values[value.TagAddress] = value;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
if (_pollTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _pollTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@page "/"
|
||||
@page "/dashboard/"
|
||||
@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.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.sessions.open"))" />
|
||||
<MetricCard Label="Workers Running" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.workers.running"))" />
|
||||
<MetricCard Label="Event Queue Depth" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.worker_queue.depth"))" />
|
||||
<MetricCard Label="Commands Failed" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.commands.failed"))" />
|
||||
<MetricCard Label="Events Received" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received"))" />
|
||||
<MetricCard Label="Faults" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.faults"))" />
|
||||
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows"))" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading d-flex align-items-center gap-2">
|
||||
<h2>Galaxy Repository</h2>
|
||||
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
|
||||
<NavLink class="ms-auto small" href="galaxy">View browse details →</NavLink>
|
||||
</div>
|
||||
<div class="metric-grid compact">
|
||||
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" />
|
||||
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
|
||||
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" />
|
||||
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
|
||||
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
|
||||
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@GalaxyRefreshDetail()" />
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
|
||||
{
|
||||
<div class="empty-state mt-2">@Snapshot.Galaxy.LastError</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Recent Faults</h2>
|
||||
</div>
|
||||
<FaultList Faults="@Snapshot.Faults" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? GalaxyRefreshDetail()
|
||||
{
|
||||
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
|
||||
if (galaxy.LastQueriedAt is null)
|
||||
{
|
||||
return "never queried";
|
||||
}
|
||||
|
||||
if (galaxy.LastSuccessAt is null)
|
||||
{
|
||||
return "no successful refresh yet";
|
||||
}
|
||||
|
||||
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
|
||||
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@page "/events"
|
||||
@page "/dashboard/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.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received"))" />
|
||||
<MetricCard Label="Worker Event Queue Depth" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.worker_queue.depth"))" />
|
||||
<MetricCard Label="Stream Queue Depth" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.grpc_stream_queue.depth"))" />
|
||||
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows"))" />
|
||||
<MetricCard Label="Stream Disconnects" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.grpc.streams.disconnected"))" />
|
||||
</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>@DashboardDisplay.Count(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,197 @@
|
||||
@page "/galaxy"
|
||||
@page "/dashboard/galaxy"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Galaxy</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading Galaxy summary.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Galaxy Repository</h1>
|
||||
<div class="text-secondary">@RefreshHeading()</div>
|
||||
</div>
|
||||
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
|
||||
</div>
|
||||
|
||||
<section class="metric-grid">
|
||||
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" Detail="@DeployAge()" Wide="true" />
|
||||
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@LastAttemptDetail()" Wide="true" />
|
||||
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
|
||||
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" Detail="dynamic, deployed" />
|
||||
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
|
||||
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
|
||||
</section>
|
||||
|
||||
@if (Snapshot.Galaxy.Status == DashboardGalaxyStatus.Unknown)
|
||||
{
|
||||
<section class="dashboard-section">
|
||||
<div class="empty-state">
|
||||
Galaxy summary has not been collected yet. The dashboard refreshes the
|
||||
summary every @RefreshIntervalSeconds() seconds via the
|
||||
<code>GalaxyRepository</code> service.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
|
||||
{
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Last Error</h2>
|
||||
</div>
|
||||
<div class="empty-state">@Snapshot.Galaxy.LastError</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Object Categories</h2>
|
||||
</div>
|
||||
@if (Snapshot.Galaxy.ObjectCategories.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No deployed objects observed.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Category ID</th>
|
||||
<th scope="col">Objects</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardGalaxyCategoryCount row in Snapshot.Galaxy.ObjectCategories)
|
||||
{
|
||||
<tr>
|
||||
<td>@row.CategoryName</td>
|
||||
<td><code>@row.CategoryId</code></td>
|
||||
<td>@DashboardDisplay.Count(row.ObjectCount)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Top Templates</h2>
|
||||
</div>
|
||||
@if (Snapshot.Galaxy.TopTemplates.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No template usage observed.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Template</th>
|
||||
<th scope="col">Instances</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardGalaxyTemplateUsage row in Snapshot.Galaxy.TopTemplates)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@row.TemplateName</code></td>
|
||||
<td>@DashboardDisplay.Count(row.InstanceCount)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Sync Info</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table details-table">
|
||||
<tbody>
|
||||
<tr><th scope="row">Status</th><td><StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" /></td></tr>
|
||||
<tr><th scope="row">Last successful refresh</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)</td></tr>
|
||||
<tr><th scope="row">Last attempt</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastQueriedAt)</td></tr>
|
||||
<tr><th scope="row">Galaxy <code>time_of_last_deploy</code></th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)</td></tr>
|
||||
<tr><th scope="row">Refresh interval</th><td>@RefreshIntervalSeconds() seconds</td></tr>
|
||||
<tr><th scope="row">Connection string</th><td><code>@DashboardDisplay.Text(GalaxyConnectionStringDisplay())</code></td></tr>
|
||||
<tr><th scope="row">Command timeout</th><td>@CommandTimeoutSeconds() seconds</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-secondary small mt-2">
|
||||
Browse data is served by the <code>galaxy_repository.v1.GalaxyRepository</code> gRPC
|
||||
service. Clients call <code>DiscoverHierarchy</code> for the full tree and
|
||||
<code>GetLastDeployTime</code> to detect redeployments.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject]
|
||||
private IOptions<ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepositoryOptions> GalaxyOptions { get; set; } = null!;
|
||||
|
||||
private string RefreshHeading()
|
||||
{
|
||||
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
|
||||
return galaxy.LastSuccessAt is null
|
||||
? "Awaiting first successful refresh"
|
||||
: $"Refreshed {DashboardDisplay.DateTime(galaxy.LastSuccessAt)}";
|
||||
}
|
||||
|
||||
private string? DeployAge()
|
||||
{
|
||||
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
|
||||
if (galaxy.LastDeployTime is null || galaxy.LastSuccessAt is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TimeSpan age = galaxy.LastSuccessAt.Value - galaxy.LastDeployTime.Value;
|
||||
if (age < TimeSpan.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"{DashboardDisplay.Duration(age)} ago";
|
||||
}
|
||||
|
||||
private string? LastAttemptDetail()
|
||||
{
|
||||
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
|
||||
if (galaxy.LastQueriedAt is null)
|
||||
{
|
||||
return "never queried";
|
||||
}
|
||||
|
||||
if (galaxy.LastSuccessAt is null)
|
||||
{
|
||||
return "no successful refresh yet";
|
||||
}
|
||||
|
||||
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
|
||||
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
|
||||
: null;
|
||||
}
|
||||
|
||||
private int RefreshIntervalSeconds() => Math.Max(1, GalaxyOptions.Value.DashboardRefreshIntervalSeconds);
|
||||
|
||||
private int CommandTimeoutSeconds() => GalaxyOptions.Value.CommandTimeoutSeconds;
|
||||
|
||||
private string GalaxyConnectionStringDisplay()
|
||||
{
|
||||
return DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString(GalaxyOptions.Value.ConnectionString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@page "/sessions/{SessionId}"
|
||||
@page "/dashboard/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">Events received</th><td>@DashboardDisplay.Count(CurrentSession.EventsReceived)</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,70 @@
|
||||
@page "/sessions"
|
||||
@page "/dashboard/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">Events</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.Count(session.EventsReceived)</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,57 @@
|
||||
@page "/settings"
|
||||
@page "/dashboard/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">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
||||
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
||||
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
|
||||
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
||||
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
||||
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||
<tr><th scope="row">LDAP username attribute</th><td>@Snapshot.Configuration.Ldap.UserNameAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP group attribute</th><td>@Snapshot.Configuration.Ldap.GroupAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP required group</th><td>@Snapshot.Configuration.Ldap.RequiredGroup</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,54 @@
|
||||
@page "/workers"
|
||||
@page "/dashboard/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,102 @@
|
||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
|
||||
|
||||
@*
|
||||
Recursive Browse hierarchy node. Renders one Galaxy object, its child
|
||||
objects (recursively), and its attributes as right-clickable tag rows.
|
||||
Expansion state is local; children render only while expanded.
|
||||
*@
|
||||
|
||||
<div class="tree-node">
|
||||
<div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")">
|
||||
@if (Node.HasChildren)
|
||||
{
|
||||
<button type="button" class="tree-toggle" @onclick="Toggle" aria-label="Toggle">
|
||||
@(_expanded ? "▾" : "▸")
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="tree-toggle tree-toggle-empty"></span>
|
||||
}
|
||||
<span class="tree-label" @onclick="Toggle">
|
||||
<span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span>
|
||||
<span class="tree-name">@Node.DisplayName</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Node.Object.TagName)
|
||||
&& !string.Equals(Node.Object.TagName, Node.DisplayName, StringComparison.Ordinal))
|
||||
{
|
||||
<code class="tree-tag">@Node.Object.TagName</code>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (_expanded)
|
||||
{
|
||||
<div class="tree-children">
|
||||
@foreach (DashboardBrowseNode child in Node.Children)
|
||||
{
|
||||
<BrowseTreeNodeView Node="child" OnAddTag="OnAddTag" OnTagContextMenu="OnTagContextMenu" />
|
||||
}
|
||||
@foreach (GalaxyAttribute attr in Node.Attributes)
|
||||
{
|
||||
GalaxyAttribute row = attr;
|
||||
<div class="tree-attr"
|
||||
title="@row.FullTagReference"
|
||||
@oncontextmenu:preventDefault="true"
|
||||
@oncontextmenu="@(args => OnTagContextMenu.InvokeAsync((args, row)))"
|
||||
@ondblclick="@(() => OnAddTag.InvokeAsync(row.FullTagReference))">
|
||||
<span class="tree-toggle tree-toggle-empty"></span>
|
||||
<span class="attr-icon">·</span>
|
||||
<span class="attr-name">@row.AttributeName</span>
|
||||
<span class="attr-type">@DisplayType(row)</span>
|
||||
@if (row.IsAlarm)
|
||||
{
|
||||
<span class="attr-flag attr-flag-alarm">alarm</span>
|
||||
}
|
||||
@if (row.IsHistorized)
|
||||
{
|
||||
<span class="attr-flag attr-flag-hist">hist</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>The hierarchy node this view renders.</summary>
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public DashboardBrowseNode Node { get; set; } = null!;
|
||||
|
||||
/// <summary>Raised with a tag's full reference when the operator double-clicks it.</summary>
|
||||
[Parameter]
|
||||
public EventCallback<string> OnAddTag { get; set; }
|
||||
|
||||
/// <summary>Raised when an attribute row is right-clicked, for the context menu.</summary>
|
||||
[Parameter]
|
||||
public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; }
|
||||
|
||||
private bool _expanded;
|
||||
|
||||
private void Toggle()
|
||||
{
|
||||
if (Node.HasChildren)
|
||||
{
|
||||
_expanded = !_expanded;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DisplayType(GalaxyAttribute attribute)
|
||||
{
|
||||
string baseType = string.IsNullOrWhiteSpace(attribute.DataTypeName)
|
||||
? "type?"
|
||||
: attribute.DataTypeName;
|
||||
if (attribute.IsArray)
|
||||
{
|
||||
return attribute.ArrayDimensionPresent
|
||||
? $"{baseType}[{attribute.ArrayDimension}]"
|
||||
: $"{baseType}[]";
|
||||
}
|
||||
|
||||
return baseType;
|
||||
}
|
||||
}
|
||||
@@ -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,25 @@
|
||||
<div class="card metric-card h-100@(Wide ? " metric-card-wide" : string.Empty)">
|
||||
<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; }
|
||||
|
||||
/// <summary>Spans the card across two grid columns for long values such as timestamps.</summary>
|
||||
[Parameter]
|
||||
public bool Wide { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<span class="chip @CssClass">@Text</span>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? Text { get; set; }
|
||||
|
||||
private string CssClass => Text switch
|
||||
{
|
||||
"Ready" or "Healthy" or "Active" => "chip-ok",
|
||||
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn",
|
||||
"Stale" or "Degraded" => "chip-warn",
|
||||
"Faulted" or "Unavailable" => "chip-bad",
|
||||
"Closed" or "Revoked" or "Unknown" => "chip-idle",
|
||||
_ => "chip-idle"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@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 ZB.MOM.WW.MxGateway.Contracts.Proto
|
||||
@using ZB.MOM.WW.MxGateway.Server.Configuration
|
||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard
|
||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Layout
|
||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
|
||||
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization
|
||||
@using ZB.MOM.WW.MxGateway.Server.Workers
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
Reference in New Issue
Block a user