From c1fe7fbc4ad63f89660756e8d2752febb141d285 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 13:53:28 -0400 Subject: [PATCH] Add Browse and Alarms dashboard tabs Browse renders the Galaxy hierarchy tree from IGalaxyHierarchyCache: expandable areas/objects with attribute name, data type and the alarm/historized flags, plus a name/reference filter. Right-click or double-click an attribute to add it to a subscription panel that polls live value, quality and source timestamp every two seconds. Alarms lists the worker's currently-active alarm set via IAlarmRpcDispatcher, defaulting to unacknowledged Active alarms with filters for acknowledged alarms, area, severity range and text. It is read-only and warns when alarm auto-subscribe is disabled. Both tabs read live MXAccess data through a new singleton DashboardLiveDataService that owns one shared, lazily-opened gateway session (one worker) for the whole dashboard, re-opened transparently if it faults or its lease expires. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/GatewayDashboardDesign.md | 43 ++ gateway.md | 11 +- .../Components/Layout/DashboardLayout.razor | 2 + .../Components/Pages/AlarmsPage.razor | 286 ++++++++++++ .../Components/Pages/BrowsePage.razor | 423 ++++++++++++++++++ .../Shared/BrowseTreeNodeView.razor | 102 +++++ .../Dashboard/DashboardActiveAlarm.cs | 63 +++ .../Dashboard/DashboardBrowseModel.cs | 95 ++++ .../Dashboard/DashboardLiveDataService.cs | 231 ++++++++++ .../Dashboard/DashboardMxValueFormatter.cs | 53 +++ .../DashboardServiceCollectionExtensions.cs | 1 + .../Dashboard/DashboardTagValue.cs | 69 +++ .../Dashboard/IDashboardLiveDataService.cs | 47 ++ .../wwwroot/css/dashboard.css | 210 +++++++++ .../DashboardBrowseAndAlarmModelTests.cs | 129 ++++++ 15 files changed, 1763 insertions(+), 2 deletions(-) create mode 100644 src/MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor create mode 100644 src/MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor create mode 100644 src/MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor create mode 100644 src/MxGateway.Server/Dashboard/DashboardActiveAlarm.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardBrowseModel.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardLiveDataService.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardMxValueFormatter.cs create mode 100644 src/MxGateway.Server/Dashboard/DashboardTagValue.cs create mode 100644 src/MxGateway.Server/Dashboard/IDashboardLiveDataService.cs create mode 100644 src/MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index 8ffeea7..8c285bb 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -254,6 +254,49 @@ Show aggregate event diagnostics: Do not display full tag values by default. If value display is later added, make it opt-in and redacted. +### Browse page + +`/dashboard/browse` lets an operator explore the Galaxy tag hierarchy and watch +live values. The tree is built in-process by `DashboardBrowseTreeBuilder` from +`IGalaxyHierarchyCache.Current` — the same cache the Galaxy page reads — so a +render costs no gRPC call and no SQL round-trip. Each node shows its child +objects and, when expanded, its attributes with attribute name, data type +(including array dimension), and the alarm / historized flags. Galaxy SQL +carries no attribute description, so none is shown. A filter box switches the +tree to a flat list of matching attributes. + +Right-clicking an attribute (or double-clicking it) adds it to the subscription +panel. The panel shows each subscribed tag's live value, MXAccess data type, +quality and source timestamp, refreshed every two seconds. The subscription +panel is the explicit opt-in tag-value surface: it always shows values +regardless of `Dashboard:ShowTagValues`, which continues to govern only the +diagnostic session/worker views. + +### Alarms page + +`/dashboard/alarms` lists the alarms the dashboard session's worker currently +reports as Active or ActiveAcked, refreshed every three seconds. It defaults to +showing unacknowledged `Active` alarms; filters add acknowledged alarms and +narrow by area, severity range, and a reference/source/description text search. +Cleared alarms are not retained — the gateway holds no alarm-history store, so +the page reflects only the live active set. The page is read-only; it does not +acknowledge alarms. If `MxGateway:Alarms:Enabled` is false the session is never +subscribed to an alarm provider, and the page says so instead of showing an +empty list with no explanation. + +### Live data source + +Both the Browse subscription panel and the Alarms page read live MXAccess data +through `IDashboardLiveDataService` (`DashboardLiveDataService`). It owns one +shared gateway session for the whole dashboard, opened lazily on first use via +`ISessionManager` and re-opened transparently when it faults or its lease +expires. One session means one worker process backs every dashboard circuit; +all access is serialised so the worker sees one in-flight command at a time. +Tag reads go through `GatewaySession.SubscribeBulkAsync` / `ReadBulkAsync`; +alarm queries go through `IAlarmRpcDispatcher`. Alarm subscription is the +gateway's existing auto-subscribe-on-open hook, so the dashboard session is +alarm-subscribed only when `MxGateway:Alarms:Enabled` is set. + ### API keys page `/dashboard/apikeys` lists the gateway's API keys and, for authorized diff --git a/gateway.md b/gateway.md index 10e6bb6..2cb494c 100644 --- a/gateway.md +++ b/gateway.md @@ -113,13 +113,20 @@ project without binding to a metrics exporter. `DashboardSnapshotService` projects sessions, workers, metrics, faults, and effective configuration into immutable DTOs for read-only dashboard rendering. The Blazor Server dashboard renders those snapshots at `/dashboard`, -`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, and -`/dashboard/settings`. Components subscribe to +`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, +`/dashboard/galaxy`, 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/browse` and `/dashboard/alarms` go beyond read-only snapshots: they +read live MXAccess data through `IDashboardLiveDataService`, which owns one +shared, lazily-opened gateway session (and therefore one worker) for the whole +dashboard. Browse walks the `IGalaxyHierarchyCache` tree and reads subscribed +tag values; Alarms lists the worker's currently-active alarm set. See +`docs/GatewayDashboardDesign.md`. + Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login` accepts the API key in a form body, validates the configured `admin` scope, and issues an HTTP-only secure cookie for subsequent dashboard requests. diff --git a/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor b/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor index 3a3de0c..29ddee2 100644 --- a/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor +++ b/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor @@ -10,6 +10,8 @@ Workers Events Galaxy + Browse + Alarms API Keys Settings diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor new file mode 100644 index 0000000..2fc06f6 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor @@ -0,0 +1,286 @@ +@page "/alarms" +@page "/dashboard/alarms" +@implements IAsyncDisposable +@inject IDashboardLiveDataService LiveData +@inject IOptions GatewayOptions + +Dashboard Alarms + +
+
+

Alarms

+
@HeaderLine()
+
+
+ +@if (!GatewayOptions.Value.Alarms.Enabled) +{ +
+ Alarm auto-subscribe is disabled (MxGateway:Alarms:Enabled 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. +
+} + +@if (!string.IsNullOrWhiteSpace(_queryError)) +{ +
Alarm query failed: @_queryError
+} + +
+ + + + +
+ +
+
+

Filters

+
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+

Active Alarms

+
+ @{ + IReadOnlyList rows = FilteredAlarms(); + } + @if (rows.Count == 0) + { +
+ @if (_alarms.Count == 0) + { + No alarms are currently Active or ActiveAcked. + } + else + { + No alarms match the current filters. + } +
+ } + else + { +
+ + + + + + + + + + + + + + + @foreach (DashboardActiveAlarm alarm in rows) + { + + + + + + + + + + + } + +
StateSeverityAlarm ReferenceSourceTypeAreaLast TransitionOperator
@StateText(alarm.State)@alarm.Severity + @alarm.Reference + @if (!string.IsNullOrWhiteSpace(alarm.Description)) + { +
@alarm.Description
+ } +
@DashboardDisplay.Text(alarm.Source)@DashboardDisplay.Text(alarm.AlarmType)@DashboardDisplay.Text(alarm.Area)@(alarm.LastTransition is { } ts ? DashboardDisplay.DateTime(ts) : "-") + @DashboardDisplay.Text(alarm.OperatorUser) + @if (!string.IsNullOrWhiteSpace(alarm.OperatorComment)) + { +
@alarm.OperatorComment
+ } +
+
+ } +
+ Cleared alarms are not retained — this list reflects only alarms currently Active or + ActiveAcked, refreshed every 3 seconds. +
+
+ +@code { + private readonly List _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; + + /// + 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 Areas() + { + return _alarms + .Select(alarm => alarm.Area) + .Where(area => !string.IsNullOrWhiteSpace(area)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(area => area, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private IReadOnlyList 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(); + } + + /// + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + if (_pollTask is not null) + { + try + { + await _pollTask; + } + catch (OperationCanceledException) + { + } + } + + _cts.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor new file mode 100644 index 0000000..2dad9c4 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor @@ -0,0 +1,423 @@ +@page "/browse" +@page "/dashboard/browse" +@implements IAsyncDisposable +@inject IGalaxyHierarchyCache GalaxyCache +@inject IDashboardLiveDataService LiveData +@using MxGateway.Contracts.Proto.Galaxy +@using MxGateway.Server.Galaxy + +Dashboard Browse + +
+
+

Browse

+
@HeaderLine()
+
+ +
+ +
+
+
+

Galaxy Hierarchy

+
+ + + @if (_roots.Count == 0) + { +
+ No Galaxy hierarchy is cached yet. The hierarchy refreshes from the + Galaxy Repository in the background — check the Galaxy tab for status. +
+ } + else if (!string.IsNullOrWhiteSpace(Search)) + { + @if (_searchMatches.Count == 0) + { +
No attributes match “@Search”.
+ } + else + { +
+ @foreach (GalaxyAttribute hit in _searchMatches) + { + GalaxyAttribute row = hit; +
+ · + @row.FullTagReference + @FormatType(row) + @if (row.IsAlarm) + { + alarm + } + @if (row.IsHistorized) + { + hist + } +
+ } +
+ @if (_searchMatches.Count >= SearchResultLimit) + { +
Showing the first @SearchResultLimit matches — refine the filter.
+ } + } + } + else + { +
+ @foreach (DashboardBrowseNode root in _roots) + { + + } +
+
Double-click a tag, or right-click for the menu.
+ } +
+ +
+
+

Subscription Panel

+
+
+ @if (_subscribed.Count > 0) + { + @_subscribed.Count subscribed + · + refresh 2s + @if (_workerPid is int pid) + { + · + worker pid @pid + } + + } +
+ + @if (!string.IsNullOrWhiteSpace(_readError)) + { +
Live read failed: @_readError
+ } + + @if (_subscribed.Count == 0) + { +
+ No tags subscribed. Right-click a tag in the hierarchy and choose + Add to subscription panel (or double-click it) to watch its + live value, quality and source timestamp here. +
+ } + else + { +
+ + + + + + + + + + + + + @foreach (string tag in _subscribed) + { + string key = tag; + DashboardTagValue? value = _values.GetValueOrDefault(key); + + + + + + + + + } + +
TagValueTypeQualityUpdated
@key@(value?.ValueText ?? "…")@(value?.DataType ?? "-") + @if (value is null) + { + + } + else + { + + @value.Quality + + @if (!string.IsNullOrWhiteSpace(value.Error)) + { + ! + } + } + @TimestampText(key, value) + +
+
+ } +
+
+ +@if (_menuVisible) +{ +
+
+
@(_menuAttribute?.AttributeName)
+ +
+} + +@code { + private const int SearchResultLimit = 300; + + private IReadOnlyList _roots = []; + private string _search = string.Empty; + private IReadOnlyList _searchMatches = []; + private readonly List _subscribed = []; + private readonly Dictionary _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 _valueSignature = new(StringComparer.Ordinal); + private readonly Dictionary _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; + + /// + 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 ComputeSearch(string rawQuery) + { + string query = rawQuery.Trim(); + if (query.Length == 0) + { + return []; + } + + List 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(); + } + + /// + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + if (_pollTask is not null) + { + try + { + await _pollTask; + } + catch (OperationCanceledException) + { + } + } + + _cts.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor b/src/MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor new file mode 100644 index 0000000..b514422 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/Components/Shared/BrowseTreeNodeView.razor @@ -0,0 +1,102 @@ +@using 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. +*@ + +
+
+ @if (Node.HasChildren) + { + + } + else + { + + } + + @(Node.IsArea ? "▣" : "◇") + @Node.DisplayName + @if (!string.IsNullOrWhiteSpace(Node.Object.TagName) + && !string.Equals(Node.Object.TagName, Node.DisplayName, StringComparison.Ordinal)) + { + @Node.Object.TagName + } + +
+ @if (_expanded) + { +
+ @foreach (DashboardBrowseNode child in Node.Children) + { + + } + @foreach (GalaxyAttribute attr in Node.Attributes) + { + GalaxyAttribute row = attr; +
+ + · + @row.AttributeName + @DisplayType(row) + @if (row.IsAlarm) + { + alarm + } + @if (row.IsHistorized) + { + hist + } +
+ } +
+ } +
+ +@code { + /// The hierarchy node this view renders. + [Parameter] + [EditorRequired] + public DashboardBrowseNode Node { get; set; } = null!; + + /// Raised with a tag's full reference when the operator double-clicks it. + [Parameter] + public EventCallback OnAddTag { get; set; } + + /// Raised when an attribute row is right-clicked, for the context menu. + [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; + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardActiveAlarm.cs b/src/MxGateway.Server/Dashboard/DashboardActiveAlarm.cs new file mode 100644 index 0000000..1b8e253 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardActiveAlarm.cs @@ -0,0 +1,63 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Server.Dashboard; + +/// +/// One active-alarm row as shown on the dashboard Alarms tab. Projected +/// from an so the Razor component never +/// touches protobuf types directly. +/// +public sealed record DashboardActiveAlarm( + string Reference, + string Provider, + string Area, + string Source, + string AlarmType, + int Severity, + AlarmConditionState State, + DateTimeOffset? LastTransition, + string OperatorUser, + string OperatorComment, + string Description) +{ + /// Projects a worker active-alarm snapshot into a dashboard alarm row. + /// The snapshot returned by QueryActiveAlarms. + /// The projected dashboard alarm. + public static DashboardActiveAlarm FromSnapshot(ActiveAlarmSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + string provider = string.Empty; + string reference = snapshot.AlarmFullReference ?? string.Empty; + int bang = reference.IndexOf('!', StringComparison.Ordinal); + if (bang > 0) + { + provider = reference[..bang]; + } + + return new DashboardActiveAlarm( + Reference: reference, + Provider: provider, + Area: snapshot.Category ?? string.Empty, + Source: snapshot.SourceObjectReference ?? string.Empty, + AlarmType: snapshot.AlarmTypeName ?? string.Empty, + Severity: snapshot.Severity, + State: snapshot.CurrentState, + LastTransition: snapshot.LastTransitionTimestamp?.ToDateTimeOffset(), + OperatorUser: snapshot.OperatorUser ?? string.Empty, + OperatorComment: snapshot.OperatorComment ?? string.Empty, + Description: snapshot.Description ?? string.Empty); + } + + /// True when this alarm is active and not yet acknowledged. + public bool IsUnacknowledged => State == AlarmConditionState.Active; +} + +/// Result of a dashboard active-alarm query. +/// The active alarms, or an empty list on error. +/// A diagnostic message when the query failed; otherwise null. +/// The worker process id backing the dashboard session, when available. +public sealed record DashboardAlarmQueryResult( + IReadOnlyList Alarms, + string? Error, + int? WorkerProcessId); diff --git a/src/MxGateway.Server/Dashboard/DashboardBrowseModel.cs b/src/MxGateway.Server/Dashboard/DashboardBrowseModel.cs new file mode 100644 index 0000000..7707fae --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardBrowseModel.cs @@ -0,0 +1,95 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Dashboard; + +/// +/// One node in the dashboard Browse hierarchy tree. Wraps a Galaxy object +/// and its child objects; the object's attributes are the leaf "tags" the +/// operator can right-click and add to the subscription panel. +/// +public sealed class DashboardBrowseNode +{ + /// The underlying Galaxy object for this node. + public required GalaxyObject Object { get; init; } + + /// Child objects contained by this object, sorted areas-first then by name. + public List Children { get; } = []; + + /// The label shown for this node in the tree. + public string DisplayName => + !string.IsNullOrWhiteSpace(Object.BrowseName) ? Object.BrowseName + : !string.IsNullOrWhiteSpace(Object.ContainedName) ? Object.ContainedName + : Object.TagName; + + /// True when this node is a Galaxy area rather than an instance object. + public bool IsArea => Object.IsArea; + + /// The object's attributes — the browsable tags. + public IReadOnlyList Attributes => Object.Attributes; + + /// True when the node has child objects or attributes to expand. + public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0; +} + +/// +/// Builds the dashboard Browse tree from the flat Galaxy object list held +/// by IGalaxyHierarchyCache. Pure and side-effect free so the +/// parent/child linkage and ordering rules are unit-testable. +/// +public static class DashboardBrowseTreeBuilder +{ + /// Builds the root nodes of the Browse tree. + /// The flat Galaxy object list. + /// The root nodes, sorted areas-first then alphabetically. + public static IReadOnlyList Build(IReadOnlyList objects) + { + ArgumentNullException.ThrowIfNull(objects); + + Dictionary nodes = new(objects.Count); + foreach (GalaxyObject galaxyObject in objects) + { + // Last write wins on a duplicate gobject id — Galaxy ids are unique + // in practice, but guard so the dictionary build never throws. + nodes[galaxyObject.GobjectId] = new DashboardBrowseNode { Object = galaxyObject }; + } + + List roots = []; + foreach (DashboardBrowseNode node in nodes.Values) + { + int parentId = node.Object.ParentGobjectId; + if (parentId != 0 + && parentId != node.Object.GobjectId + && nodes.TryGetValue(parentId, out DashboardBrowseNode? parent)) + { + parent.Children.Add(node); + } + else + { + roots.Add(node); + } + } + + SortRecursive(roots); + return roots; + } + + private static void SortRecursive(List nodes) + { + nodes.Sort(CompareNodes); + foreach (DashboardBrowseNode node in nodes) + { + SortRecursive(node.Children); + } + } + + // Areas sort before instance objects; within a group, by display name. + private static int CompareNodes(DashboardBrowseNode left, DashboardBrowseNode right) + { + if (left.IsArea != right.IsArea) + { + return left.IsArea ? -1 : 1; + } + + return string.Compare(left.DisplayName, right.DisplayName, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardLiveDataService.cs b/src/MxGateway.Server/Dashboard/DashboardLiveDataService.cs new file mode 100644 index 0000000..6efe838 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardLiveDataService.cs @@ -0,0 +1,231 @@ +using MxGateway.Contracts.Proto; +using MxGateway.Server.Sessions; + +namespace MxGateway.Server.Dashboard; + +/// +/// Default . Owns one shared gateway +/// session for the whole dashboard: it is opened lazily on first use and +/// re-opened transparently whenever it faults, is closed, or its lease +/// expires. All access is serialised through so the +/// single backing worker only ever sees one in-flight command. +/// +public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable +{ + private const string BackendName = "Galaxy"; + private const string ClientName = "mxgateway-dashboard"; + private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5); + + private readonly ISessionManager _sessionManager; + private readonly IAlarmRpcDispatcher _alarmDispatcher; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly HashSet _subscribed = new(StringComparer.OrdinalIgnoreCase); + + private GatewaySession? _session; + private int _serverHandle; + private bool _disposed; + + /// Initializes the live-data service. + /// Gateway session manager. + /// Active-alarm query dispatcher. + /// Diagnostic logger. + public DashboardLiveDataService( + ISessionManager sessionManager, + IAlarmRpcDispatcher alarmDispatcher, + ILogger logger) + { + _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager)); + _alarmDispatcher = alarmDispatcher ?? throw new ArgumentNullException(nameof(alarmDispatcher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task ReadAsync( + IReadOnlyCollection tagAddresses, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(tagAddresses); + if (tagAddresses.Count == 0) + { + return DashboardLiveReadResult.Empty; + } + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + (GatewaySession session, int serverHandle) = await EnsureReadyAsync(cancellationToken) + .ConfigureAwait(false); + + string[] toSubscribe = tagAddresses.Where(tag => !_subscribed.Contains(tag)).ToArray(); + if (toSubscribe.Length > 0) + { + await session.SubscribeBulkAsync(serverHandle, toSubscribe, cancellationToken) + .ConfigureAwait(false); + foreach (string tag in toSubscribe) + { + _subscribed.Add(tag); + } + } + + IReadOnlyList results = await session + .ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken) + .ConfigureAwait(false); + + DashboardTagValue[] values = results + .Select(DashboardTagValue.FromBulkReadResult) + .ToArray(); + return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + InvalidateSession(); + _logger.LogWarning(exception, "Dashboard live read failed; the dashboard session will be re-opened."); + return new DashboardLiveReadResult([], exception.Message, null, null); + } + finally + { + _gate.Release(); + } + } + + /// + public async Task QueryAlarmsAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + (GatewaySession session, _) = await EnsureReadyAsync(cancellationToken).ConfigureAwait(false); + + QueryActiveAlarmsRequest request = new() + { + SessionId = session.SessionId, + ClientCorrelationId = Guid.NewGuid().ToString("N"), + }; + + List alarms = []; + await foreach (ActiveAlarmSnapshot snapshot in _alarmDispatcher + .QueryActiveAlarmsAsync(request, cancellationToken) + .ConfigureAwait(false)) + { + alarms.Add(DashboardActiveAlarm.FromSnapshot(snapshot)); + } + + return new DashboardAlarmQueryResult(alarms, null, session.WorkerProcessId); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + InvalidateSession(); + _logger.LogWarning(exception, "Dashboard alarm query failed; the dashboard session will be re-opened."); + return new DashboardAlarmQueryResult([], exception.Message, null); + } + finally + { + _gate.Release(); + } + } + + // Returns a Ready session + its Register server handle, opening a fresh + // session when none exists or the current one is no longer usable. Callers + // must hold _gate. + private async Task<(GatewaySession Session, int ServerHandle)> EnsureReadyAsync( + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + GatewaySession? existing = _session; + if (existing is not null + && existing.State == SessionState.Ready + && _sessionManager.TryGetSession(existing.SessionId, out _)) + { + return (existing, _serverHandle); + } + + if (existing is not null) + { + _logger.LogInformation( + "Dashboard session {SessionId} is no longer usable (state {State}); re-opening.", + existing.SessionId, + existing.State); + await CloseQuietlyAsync(existing.SessionId).ConfigureAwait(false); + } + + _subscribed.Clear(); + _session = null; + + GatewaySession session = await _sessionManager.OpenSessionAsync( + new SessionOpenRequest(BackendName, ClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null), + ClientName, + cancellationToken) + .ConfigureAwait(false); + + WorkerCommandReply reply = await session.InvokeAsync( + new WorkerCommand + { + Command = new MxCommand + { + Kind = MxCommandKind.Register, + Register = new RegisterCommand { ClientName = ClientName }, + }, + }, + cancellationToken) + .ConfigureAwait(false); + + int? serverHandle = reply.Reply?.Register?.ServerHandle; + if (serverHandle is null) + { + string diagnostic = reply.Reply?.ProtocolStatus?.Message + ?? reply.Reply?.DiagnosticMessage + ?? "Worker did not return a server handle for Register."; + await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false); + throw new InvalidOperationException($"Dashboard session registration failed: {diagnostic}"); + } + + _session = session; + _serverHandle = serverHandle.Value; + _logger.LogInformation( + "Dashboard session {SessionId} opened (worker pid {WorkerPid}).", + session.SessionId, + session.WorkerProcessId); + return (session, _serverHandle); + } + + // Drops the cached session so the next call re-opens. Callers must hold _gate. + private void InvalidateSession() + { + _session = null; + _serverHandle = 0; + _subscribed.Clear(); + } + + private async Task CloseQuietlyAsync(string sessionId) + { + try + { + await _sessionManager.CloseSessionAsync(sessionId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Closing stale dashboard session {SessionId} failed.", sessionId); + } + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + GatewaySession? session = _session; + _session = null; + if (session is not null) + { + await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false); + } + + _gate.Dispose(); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardMxValueFormatter.cs b/src/MxGateway.Server/Dashboard/DashboardMxValueFormatter.cs new file mode 100644 index 0000000..545fdea --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardMxValueFormatter.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Server.Dashboard; + +/// +/// Formats an into the short, human-readable text the +/// dashboard's Browse subscription panel shows. Kept separate from the +/// view layer so the formatting rules are unit-testable without a worker. +/// +public static class DashboardMxValueFormatter +{ + /// Formats the value payload of an . + /// The value to format; may be null. + /// A display string — never null. + public static string FormatValue(MxValue? value) + { + if (value is null) + { + return "-"; + } + + if (value.IsNull) + { + return "(null)"; + } + + return value.KindCase switch + { + MxValue.KindOneofCase.BoolValue => value.BoolValue ? "true" : "false", + MxValue.KindOneofCase.Int32Value => value.Int32Value.ToString(CultureInfo.InvariantCulture), + MxValue.KindOneofCase.Int64Value => value.Int64Value.ToString(CultureInfo.InvariantCulture), + MxValue.KindOneofCase.FloatValue => value.FloatValue.ToString("G7", CultureInfo.InvariantCulture), + MxValue.KindOneofCase.DoubleValue => value.DoubleValue.ToString("G15", CultureInfo.InvariantCulture), + MxValue.KindOneofCase.StringValue => value.StringValue, + MxValue.KindOneofCase.TimestampValue => value.TimestampValue + .ToDateTimeOffset() + .UtcDateTime + .ToString("yyyy-MM-dd HH:mm:ss.fff 'UTC'", CultureInfo.InvariantCulture), + MxValue.KindOneofCase.ArrayValue => "(array)", + MxValue.KindOneofCase.RawValue => $"({value.RawValue.Length} bytes)", + _ => "-", + }; + } + + /// Formats the MXAccess data type of an . + /// The value whose data type to describe; may be null. + /// The data-type name — never null. + public static string FormatDataType(MxValue? value) + { + return value is null ? "-" : value.DataType.ToString(); + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index 1eea466..ce3d70f 100644 --- a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static class DashboardServiceCollectionExtensions public static IServiceCollection AddGatewayDashboard(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/MxGateway.Server/Dashboard/DashboardTagValue.cs b/src/MxGateway.Server/Dashboard/DashboardTagValue.cs new file mode 100644 index 0000000..041c4f2 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardTagValue.cs @@ -0,0 +1,69 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Server.Dashboard; + +/// +/// One live tag value as shown in the Browse subscription panel. Projected +/// from a worker so the Razor component never +/// touches protobuf types directly. +/// +public sealed record DashboardTagValue( + string TagAddress, + bool Ok, + string ValueText, + string DataType, + int Quality, + bool QualityGood, + DateTimeOffset? SourceTimestamp, + string? Error) +{ + /// + /// Classic OPC-DA "Good" quality. MXAccess surfaces 192 for a healthy + /// advised value; anything lower is uncertain or bad. + /// + private const int GoodQualityThreshold = 192; + + /// Projects a worker bulk-read result into a dashboard tag value. + /// The per-tag result from a ReadBulk reply. + /// The projected dashboard value. + public static DashboardTagValue FromBulkReadResult(BulkReadResult result) + { + ArgumentNullException.ThrowIfNull(result); + + string? error = null; + if (!result.WasSuccessful) + { + error = !string.IsNullOrWhiteSpace(result.ErrorMessage) + ? result.ErrorMessage + : FirstStatusDiagnostic(result); + } + + return new DashboardTagValue( + TagAddress: result.TagAddress, + Ok: result.WasSuccessful, + ValueText: DashboardMxValueFormatter.FormatValue(result.Value), + DataType: DashboardMxValueFormatter.FormatDataType(result.Value), + Quality: result.Quality, + QualityGood: result.WasSuccessful && result.Quality >= GoodQualityThreshold, + SourceTimestamp: result.SourceTimestamp?.ToDateTimeOffset(), + Error: error); + } + + private static string? FirstStatusDiagnostic(BulkReadResult result) + { + foreach (MxStatusProxy status in result.Statuses) + { + if (!string.IsNullOrWhiteSpace(status.DiagnosticText)) + { + return status.DiagnosticText; + } + + if (status.Category != MxStatusCategory.Ok) + { + return status.Category.ToString(); + } + } + + return null; + } +} diff --git a/src/MxGateway.Server/Dashboard/IDashboardLiveDataService.cs b/src/MxGateway.Server/Dashboard/IDashboardLiveDataService.cs new file mode 100644 index 0000000..06db2b0 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/IDashboardLiveDataService.cs @@ -0,0 +1,47 @@ +namespace MxGateway.Server.Dashboard; + +/// +/// Supplies the Browse and Alarms dashboard tabs with live MXAccess data. +/// Owns one shared, lazily-opened gateway session (and therefore one +/// worker process) used by every dashboard circuit; the session is +/// transparently re-opened if it faults or its lease expires. +/// +public interface IDashboardLiveDataService +{ + /// + /// Subscribes (once) and reads the current value, quality and timestamp + /// of the supplied tags. Never throws — transport and session failures + /// are surfaced in . + /// + /// Fully-qualified tag references to read. + /// Token to cancel the read. + /// The read result, or an error-bearing result on failure. + Task ReadAsync( + IReadOnlyCollection tagAddresses, + CancellationToken cancellationToken); + + /// + /// Queries the currently-active alarm set for the dashboard session. + /// Never throws — failures are surfaced in + /// . + /// + /// Token to cancel the query. + /// The active alarms, or an error-bearing result on failure. + Task QueryAlarmsAsync(CancellationToken cancellationToken); +} + +/// Result of a dashboard live tag read. +/// The per-tag values, or an empty list on error. +/// A diagnostic message when the read failed; otherwise null. +/// The dashboard session id used, when available. +/// The worker process id backing the session, when available. +public sealed record DashboardLiveReadResult( + IReadOnlyList Values, + string? Error, + string? SessionId, + int? WorkerProcessId) +{ + /// An empty, successful result — used when no tags are subscribed. + public static DashboardLiveReadResult Empty { get; } = + new([], null, null, null); +} diff --git a/src/MxGateway.Server/wwwroot/css/dashboard.css b/src/MxGateway.Server/wwwroot/css/dashboard.css index 9ab69cd..47ef3aa 100644 --- a/src/MxGateway.Server/wwwroot/css/dashboard.css +++ b/src/MxGateway.Server/wwwroot/css/dashboard.css @@ -306,3 +306,213 @@ code { } .details-table th { width: 9rem; } } + +/* ── Browse tab ─────────────────────────────────────────────────────────────── + Two-pane layout: the Galaxy hierarchy tree on the left, the live + subscription panel on the right. Both panes are .dashboard-section cards. */ +.browse-layout { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + align-items: start; +} +.browse-panel { margin-top: 0; } + +.browse-search { margin-bottom: 0.6rem; } +.browse-search-note { + margin-top: 0.5rem; + font-size: 0.74rem; + color: var(--ink-faint); +} + +.browse-tree { + max-height: 32rem; + overflow-y: auto; + border: 1px solid var(--rule); + border-radius: 6px; + padding: 0.3rem 0; + background: #fbfbf9; +} +.browse-search-results { padding: 0.15rem 0; } + +.tree-children { margin-left: 0.95rem; border-left: 1px solid var(--rule); } + +.tree-row, +.tree-attr { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.12rem 0.5rem; + font-size: 0.82rem; + white-space: nowrap; +} +.tree-row:hover, +.tree-attr:hover { background: #eef2fb; } + +.tree-toggle { + flex: none; + width: 1.1rem; + height: 1.1rem; + line-height: 1; + padding: 0; + border: none; + background: transparent; + color: var(--ink-faint); + font-size: 0.7rem; + cursor: pointer; +} +.tree-toggle-empty { cursor: default; } + +.tree-label { + display: flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; +} +.tree-icon { color: var(--accent); font-size: 0.72rem; } +.tree-name { color: var(--ink); } +.tree-row-area .tree-name { font-weight: 600; } +.tree-tag { font-size: 0.72rem; color: var(--ink-faint); } + +.tree-attr { cursor: context-menu; } +.attr-icon { flex: none; color: var(--ink-faint); width: 0.6rem; text-align: center; } +.attr-name { font-family: var(--mono); color: var(--ink); } +.attr-type { font-size: 0.72rem; color: var(--ink-faint); } +.attr-flag { + font-size: 0.62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.02rem 0.28rem; + border-radius: 3px; +} +.attr-flag-alarm { color: var(--bad); background: var(--bad-bg); } +.attr-flag-hist { color: var(--accent-deep); background: #e7ecfb; } + +/* ── Subscription panel ───────────────────────────────────────────────────── */ +.sub-panel-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.76rem; + color: var(--ink-faint); + margin-bottom: 0.5rem; + min-height: 1.6rem; +} +.sub-clear { margin-left: auto; } +.sub-table td { vertical-align: middle; } +.sub-value { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + color: var(--ink); + font-weight: 600; +} +.sub-actions-col { width: 1%; white-space: nowrap; } + +.quality-chip { + font-family: var(--mono); + font-size: 0.74rem; + font-weight: 600; + padding: 0.05rem 0.4rem; + border-radius: 3px; +} +.quality-good { color: var(--ok); background: var(--ok-bg); } +.quality-bad { color: var(--bad); background: var(--bad-bg); } +.sub-error { + margin-left: 0.3rem; + color: var(--bad); + font-weight: 700; + cursor: help; +} + +/* ── Right-click context menu ─────────────────────────────────────────────── */ +.context-menu-overlay { + position: fixed; + inset: 0; + z-index: 1040; + background: transparent; +} +.context-menu { + position: fixed; + z-index: 1050; + min-width: 13rem; + background: var(--card); + border: 1px solid var(--rule-strong); + border-radius: 6px; + box-shadow: 0 6px 20px rgba(27, 29, 33, 0.16); + padding: 0.25rem; +} +.context-menu-head { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-faint); + padding: 0.3rem 0.5rem; + border-bottom: 1px solid var(--rule); + margin-bottom: 0.2rem; + overflow: hidden; + text-overflow: ellipsis; +} +.context-menu-item { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + color: var(--ink); + font-size: 0.83rem; + padding: 0.4rem 0.5rem; + border-radius: 4px; + cursor: pointer; +} +.context-menu-item:hover { background: #eef2fb; color: var(--accent-deep); } + +/* ── Alarms tab ───────────────────────────────────────────────────────────── */ +.alarm-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.75rem 1rem; +} +.alarm-filter-check { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.82rem; + color: var(--ink-soft); +} +.alarm-filter-field { display: flex; flex-direction: column; gap: 0.2rem; } +.alarm-filter-field input[type="number"] { width: 7rem; } +.alarm-filter-grow { flex: 1 1 14rem; } +.alarm-filter-grow input { width: 100%; } + +.alarm-state { + display: inline-block; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1rem 0.45rem; + border-radius: 3px; + white-space: nowrap; +} +.alarm-state-active { color: var(--bad); background: var(--bad-bg); } +.alarm-state-acked { color: var(--warn); background: var(--warn-bg); } +.alarm-state-other { color: var(--idle); background: var(--idle-bg); } +.alarm-severity { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-weight: 600; +} +.alarm-desc { + margin-top: 0.1rem; + font-size: 0.74rem; + color: var(--ink-faint); +} + +@media (max-width: 960px) { + .browse-layout { grid-template-columns: minmax(0, 1fr); } +} diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs new file mode 100644 index 0000000..e226f10 --- /dev/null +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardBrowseAndAlarmModelTests.cs @@ -0,0 +1,129 @@ +using MxGateway.Contracts.Proto; +using MxGateway.Contracts.Proto.Galaxy; +using MxGateway.Server.Dashboard; + +namespace MxGateway.Tests.Gateway.Dashboard; + +/// +/// Unit tests for the pure projection/formatting helpers behind the +/// dashboard Browse and Alarms tabs. +/// +public sealed class DashboardBrowseAndAlarmModelTests +{ + [Fact] + public void BuildTree_LinksChildrenToParents_AndPromotesOrphansToRoots() + { + GalaxyObject area = new() { GobjectId = 1, BrowseName = "AreaA", IsArea = true, ParentGobjectId = 0 }; + GalaxyObject child = new() { GobjectId = 2, BrowseName = "Pump01", ParentGobjectId = 1 }; + GalaxyObject orphan = new() { GobjectId = 3, BrowseName = "Lost", ParentGobjectId = 99 }; + + IReadOnlyList roots = DashboardBrowseTreeBuilder.Build([area, child, orphan]); + + // The area and the orphan (its parent id is absent) are both roots. + Assert.Equal(2, roots.Count); + DashboardBrowseNode areaNode = Assert.Single(roots, node => node.Object.GobjectId == 1); + Assert.Single(areaNode.Children); + Assert.Equal(2, areaNode.Children[0].Object.GobjectId); + Assert.Contains(roots, node => node.Object.GobjectId == 3); + } + + [Fact] + public void BuildTree_SortsAreasBeforeObjects() + { + GalaxyObject instance = new() { GobjectId = 1, BrowseName = "Zeta", IsArea = false }; + GalaxyObject areaB = new() { GobjectId = 2, BrowseName = "Beta", IsArea = true }; + + IReadOnlyList roots = DashboardBrowseTreeBuilder.Build([instance, areaB]); + + Assert.Equal(2, roots.Count); + Assert.True(roots[0].IsArea); + Assert.Equal("Beta", roots[0].DisplayName); + Assert.False(roots[1].IsArea); + } + + [Theory] + [InlineData(true, "true")] + [InlineData(false, "false")] + public void FormatValue_FormatsBooleans(bool input, string expected) + { + MxValue value = new() { DataType = MxDataType.Boolean, BoolValue = input }; + Assert.Equal(expected, DashboardMxValueFormatter.FormatValue(value)); + } + + [Fact] + public void FormatValue_FormatsNumbersAndStrings() + { + Assert.Equal("42", DashboardMxValueFormatter.FormatValue(new MxValue { Int32Value = 42 })); + Assert.Equal("hello", DashboardMxValueFormatter.FormatValue(new MxValue { StringValue = "hello" })); + } + + [Fact] + public void FormatValue_HandlesNullPayloadAndNullReference() + { + Assert.Equal("-", DashboardMxValueFormatter.FormatValue(null)); + Assert.Equal("(null)", DashboardMxValueFormatter.FormatValue(new MxValue { IsNull = true })); + } + + [Fact] + public void TagValue_FromSuccessfulReadResult_MarksGoodQuality() + { + BulkReadResult result = new() + { + TagAddress = "Galaxy!Area.Tag", + WasSuccessful = true, + Quality = 192, + Value = new MxValue { DataType = MxDataType.Double, DoubleValue = 1.5 }, + }; + + DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result); + + Assert.True(value.Ok); + Assert.True(value.QualityGood); + Assert.Equal("1.5", value.ValueText); + Assert.Null(value.Error); + } + + [Fact] + public void TagValue_FromFailedReadResult_CarriesError() + { + BulkReadResult result = new() + { + TagAddress = "Galaxy!Area.Bad", + WasSuccessful = false, + Quality = 0, + ErrorMessage = "invalid handle", + }; + + DashboardTagValue value = DashboardTagValue.FromBulkReadResult(result); + + Assert.False(value.Ok); + Assert.False(value.QualityGood); + Assert.Equal("invalid handle", value.Error); + } + + [Fact] + public void ActiveAlarm_FromSnapshot_ParsesProviderAndAcknowledgementState() + { + ActiveAlarmSnapshot unacked = new() + { + AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", + Category = "TestArea", + CurrentState = AlarmConditionState.Active, + Severity = 500, + }; + ActiveAlarmSnapshot acked = new() + { + AlarmFullReference = "Galaxy!TestArea.TestMachine_002.TestAlarm001", + CurrentState = AlarmConditionState.ActiveAcked, + }; + + DashboardActiveAlarm unackedRow = DashboardActiveAlarm.FromSnapshot(unacked); + DashboardActiveAlarm ackedRow = DashboardActiveAlarm.FromSnapshot(acked); + + Assert.Equal("Galaxy", unackedRow.Provider); + Assert.Equal("TestArea", unackedRow.Area); + Assert.Equal(500, unackedRow.Severity); + Assert.True(unackedRow.IsUnacknowledged); + Assert.False(ackedRow.IsUnacknowledged); + } +}