Time-series history persisted + per-item trend sparklines in detail view. Data (Domain+Persistence): HistoryPoint model; IMachineRepository +Append/ Get/Prune history; snapshot_history table (append-only) + index; DeleteAsync cascades history. Port purity kept (Result/Cancelled, no throw). Core: MachineMonitor.PersistAsync appends history on Connected snapshots + throttled retention prune (7-day window, <=1/hour via Interlocked-CAS), never blocks/undoes latest-snapshot save. App: custom Sparkline Control (StreamGeometry polyline, min/max-scaled, net48-safe, no charting dep); DataItemRowViewModel observable +Trend/HasTrend + pure TryParseNumeric; MachineDetailViewModel loads per-item history (1h window, 60 pts) on load + each snapshot, parses numeric, sets Trend on UI thread; detail view Trend column. Projects: Domain, Persistence, Core, App, Tests (+31: 196 pass + 2 skip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
386 lines
15 KiB
C#
386 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Avalonia.Threading;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Junction.Core.Monitoring;
|
|
using Junction.Domain.Models;
|
|
using Junction.Domain.Persistence;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Junction.App.ViewModels
|
|
{
|
|
/// <summary>
|
|
/// Full current read of a single machine: header (machine config) + the complete latest
|
|
/// snapshot (all data items + connection state + capture time). Seeds from the monitor's
|
|
/// latest-snapshot cache (falling back to the repository), then refreshes live as
|
|
/// <see cref="IMachineMonitor.SnapshotUpdated"/> fires for this machine. Snapshot events
|
|
/// arrive on poll-loop threads and are marshalled onto the UI thread.
|
|
/// <para>Created per-navigation (transient); call <see cref="Initialize"/> then
|
|
/// <see cref="LoadAsync"/>. <see cref="Navigator"/> is set by the shell to route Back.</para>
|
|
/// </summary>
|
|
public sealed partial class MachineDetailViewModel : ViewModelBase, IDisposable
|
|
{
|
|
/// <summary>How far back the per-item trend sparkline looks.</summary>
|
|
private static readonly TimeSpan TrendWindow = TimeSpan.FromHours(1);
|
|
|
|
/// <summary>Cap on samples pulled per item for a sparkline (most-recent kept).</summary>
|
|
private const int TrendMaxPoints = 60;
|
|
|
|
private readonly IMachineRepository _repository;
|
|
private readonly IMachineMonitor _monitor;
|
|
private readonly ILogger<MachineDetailViewModel> _logger;
|
|
private Guid _machineId;
|
|
private bool _subscribed;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Handle on the most recently kicked (fire-and-forget) trend load. Production ignores it;
|
|
/// tests await it to observe the populated <see cref="DataItemRowViewModel.Trend"/> values.
|
|
/// </summary>
|
|
public Task? TrendLoadTask { get; private set; }
|
|
|
|
/// <summary>Shell reference used by <see cref="BackCommand"/> to return to the dashboard.</summary>
|
|
public MainWindowViewModel? Navigator { get; set; }
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(DeleteConfirmPrompt))]
|
|
private string _machineName = "—";
|
|
|
|
[ObservableProperty] private string _protocolId = "—";
|
|
[ObservableProperty] private string _machineIdText = "—";
|
|
[ObservableProperty] private string _pollInterval = "—";
|
|
[ObservableProperty] private string _agentUrl = "—";
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsOnline))]
|
|
[NotifyPropertyChangedFor(nameof(IsDisconnected))]
|
|
[NotifyPropertyChangedFor(nameof(StatusKind))]
|
|
private ConnectionState _connectionState = ConnectionState.Unknown;
|
|
|
|
[ObservableProperty] private string _capturedAt = "—";
|
|
|
|
/// <summary>Timestamp of the last CONNECTED snapshot; "—" until first connect.</summary>
|
|
[ObservableProperty] private string _lastSeen = "—";
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(HasItems))]
|
|
[NotifyPropertyChangedFor(nameof(HasNoItems))]
|
|
private int _itemCount;
|
|
|
|
/// <summary>True when there is at least one monitored data item to display.</summary>
|
|
public bool HasItems => ItemCount > 0;
|
|
|
|
/// <summary>True when nothing is monitored — drives the empty-state hint instead of a blank table.</summary>
|
|
public bool HasNoItems => ItemCount == 0;
|
|
|
|
/// <summary>True only when currently connected.</summary>
|
|
public bool IsOnline => ConnectionState == ConnectionState.Connected;
|
|
|
|
/// <summary>True when the machine is offline (disconnected or errored) — drives the red header badge.</summary>
|
|
public bool IsDisconnected =>
|
|
ConnectionState == ConnectionState.Disconnected || ConnectionState == ConnectionState.Error;
|
|
|
|
/// <summary>UI-agnostic status token ("online"/"offline"/"unknown") the View maps to a color.</summary>
|
|
public string StatusKind
|
|
{
|
|
get
|
|
{
|
|
switch (ConnectionState)
|
|
{
|
|
case ConnectionState.Connected:
|
|
return "online";
|
|
case ConnectionState.Disconnected:
|
|
case ConnectionState.Error:
|
|
return "offline";
|
|
default:
|
|
return "unknown";
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Controls visibility of the delete-confirmation overlay.</summary>
|
|
[ObservableProperty] private bool _isDeleteConfirmVisible;
|
|
|
|
/// <summary>Named confirmation prompt, e.g. "Delete 'VMC Sim (mock)'?".</summary>
|
|
public string DeleteConfirmPrompt => "Delete '" + MachineName + "'?";
|
|
|
|
/// <summary>The full set of current data items for this machine.</summary>
|
|
public ObservableCollection<DataItemRowViewModel> Items { get; } =
|
|
new ObservableCollection<DataItemRowViewModel>();
|
|
|
|
public MachineDetailViewModel(
|
|
IMachineRepository repository,
|
|
IMachineMonitor monitor,
|
|
ILogger<MachineDetailViewModel> logger)
|
|
{
|
|
_repository = repository;
|
|
_monitor = monitor;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>Sets the target machine. Call before <see cref="LoadAsync"/>.</summary>
|
|
public void Initialize(Guid machineId, string machineName)
|
|
{
|
|
_machineId = machineId;
|
|
MachineName = string.IsNullOrWhiteSpace(machineName) ? "—" : machineName;
|
|
MachineIdText = machineId.ToString();
|
|
}
|
|
|
|
/// <summary>Loads the machine header + latest snapshot and subscribes to live updates.</summary>
|
|
public async Task LoadAsync()
|
|
{
|
|
// Subscribe first so no update slips through between load and subscribe;
|
|
// events for other machines are ignored by id.
|
|
if (!_subscribed)
|
|
{
|
|
_monitor.SnapshotUpdated += OnSnapshotUpdated;
|
|
_subscribed = true;
|
|
}
|
|
|
|
var machineResult = await _repository.GetByIdAsync(_machineId, CancellationToken.None).ConfigureAwait(true);
|
|
if (machineResult.IsSuccess)
|
|
{
|
|
var m = machineResult.Value;
|
|
MachineName = string.IsNullOrWhiteSpace(m.Name) ? "—" : m.Name;
|
|
ProtocolId = string.IsNullOrWhiteSpace(m.ProtocolId) ? "—" : m.ProtocolId;
|
|
PollInterval = m.PollInterval.ToString();
|
|
AgentUrl = m.ConnectionConfig.TryGetValue("AgentUrl", out var url) && !string.IsNullOrWhiteSpace(url)
|
|
? url
|
|
: "—";
|
|
}
|
|
else
|
|
{
|
|
var detail = machineResult.Errors.Count > 0 ? machineResult.Errors[0].Message : "unknown error";
|
|
_logger.LogError("Machine detail load (GetById) failed for {MachineId}: {Detail}", _machineId, detail);
|
|
}
|
|
|
|
// Prefer the monitor's live cache; fall back to the persisted latest snapshot.
|
|
MachineSnapshot? snapshot = null;
|
|
if (_monitor.LatestSnapshots.TryGetValue(_machineId, out var cached))
|
|
{
|
|
snapshot = cached;
|
|
}
|
|
else
|
|
{
|
|
var snapResult = await _repository.GetLatestSnapshotAsync(_machineId, CancellationToken.None).ConfigureAwait(true);
|
|
if (snapResult.IsSuccess)
|
|
{
|
|
snapshot = snapResult.Value;
|
|
}
|
|
}
|
|
|
|
if (snapshot != null)
|
|
{
|
|
ApplySnapshot(snapshot);
|
|
KickTrendLoad();
|
|
}
|
|
|
|
_logger.LogInformation("Machine detail opened for {Name} ({MachineId}); {Count} item(s).",
|
|
MachineName, _machineId, ItemCount);
|
|
}
|
|
|
|
/// <summary>Projects a snapshot onto the header state + item table. Call on the UI thread.</summary>
|
|
private void ApplySnapshot(MachineSnapshot snapshot)
|
|
{
|
|
ConnectionState = snapshot.ConnectionState;
|
|
CapturedAt = snapshot.CapturedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
|
|
|
|
if (snapshot.ConnectionState == ConnectionState.Connected)
|
|
{
|
|
LastSeen = snapshot.CapturedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
|
|
}
|
|
|
|
// Synthetic Disconnected snapshots carry no items; keep the last-known values on
|
|
// screen (alongside the Disconnected badge + Last Seen) instead of blanking the table.
|
|
if (snapshot.Items.Count > 0 || snapshot.ConnectionState == ConnectionState.Connected)
|
|
{
|
|
Items.Clear();
|
|
for (int i = 0; i < snapshot.Items.Count; i++)
|
|
{
|
|
Items.Add(new DataItemRowViewModel(snapshot.Items[i]));
|
|
}
|
|
|
|
ItemCount = Items.Count;
|
|
}
|
|
}
|
|
|
|
private void OnSnapshotUpdated(object? sender, MachineSnapshot snapshot)
|
|
{
|
|
if (snapshot == null || snapshot.MachineId != _machineId)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Event fires on a poll-loop thread → marshal all observable mutations to the UI thread.
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ApplySnapshot(snapshot);
|
|
KickTrendLoad();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fires (fire-and-forget) a per-item history query for every current row and, for items
|
|
/// whose samples parse as numeric, populates the row's <see cref="DataItemRowViewModel.Trend"/>
|
|
/// so its sparkline renders. Rebuilt rows carry no stale trend. A per-item failure never
|
|
/// aborts the others and never propagates.
|
|
/// </summary>
|
|
private void KickTrendLoad()
|
|
{
|
|
TrendLoadTask = LoadTrendsAsync();
|
|
}
|
|
|
|
private async Task LoadTrendsAsync()
|
|
{
|
|
// Snapshot the rows: the collection may be rebuilt by a later snapshot while we await.
|
|
var rows = Items.ToArray();
|
|
var since = DateTimeOffset.UtcNow - TrendWindow;
|
|
|
|
for (int r = 0; r < rows.Length; r++)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var row = rows[r];
|
|
try
|
|
{
|
|
var result = await _repository
|
|
.GetHistoryAsync(_machineId, row.Id, since, TrendMaxPoints, CancellationToken.None)
|
|
.ConfigureAwait(true);
|
|
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!result.IsSuccess || result.Value == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var history = result.Value;
|
|
var values = new List<double>(history.Count);
|
|
for (int i = 0; i < history.Count; i++)
|
|
{
|
|
if (DataItemRowViewModel.TryParseNumeric(history[i].Value, out var d))
|
|
{
|
|
values.Add(d);
|
|
}
|
|
}
|
|
|
|
// Non-numeric / empty history → leave Trend null (sparkline stays blank).
|
|
if (values.Count > 0)
|
|
{
|
|
SetTrendOnUi(row, values.ToArray());
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Trend load failed for item {ItemId} on machine {MachineId}.",
|
|
row.Id, _machineId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SetTrendOnUi(DataItemRowViewModel row, IReadOnlyList<double> trend)
|
|
{
|
|
if (Dispatcher.UIThread.CheckAccess())
|
|
{
|
|
row.Trend = trend;
|
|
}
|
|
else
|
|
{
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (!_disposed)
|
|
{
|
|
row.Trend = trend;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Back()
|
|
{
|
|
Dispose();
|
|
Navigator?.GoToDashboard();
|
|
}
|
|
|
|
/// <summary>Opens the edit config screen for this machine.</summary>
|
|
[RelayCommand]
|
|
private void Edit()
|
|
{
|
|
Dispose();
|
|
Navigator?.ShowConfig(_machineId);
|
|
}
|
|
|
|
/// <summary>Opens the named delete-confirmation overlay.</summary>
|
|
[RelayCommand]
|
|
private void RequestDelete() => IsDeleteConfirmVisible = true;
|
|
|
|
/// <summary>Dismisses the delete-confirmation overlay without deleting.</summary>
|
|
[RelayCommand]
|
|
private void CancelDelete() => IsDeleteConfirmVisible = false;
|
|
|
|
/// <summary>
|
|
/// Confirmed delete: performs the delete (repository + live monitor removal) and returns to
|
|
/// the reloaded dashboard. Only reachable from the confirmation overlay.
|
|
/// </summary>
|
|
[RelayCommand]
|
|
private async Task ConfirmDelete()
|
|
{
|
|
IsDeleteConfirmVisible = false;
|
|
|
|
var del = await _repository.DeleteAsync(_machineId, CancellationToken.None).ConfigureAwait(true);
|
|
if (!del.IsSuccess)
|
|
{
|
|
var detail = del.Errors.Count > 0 ? del.Errors[0].Message : "unknown error";
|
|
_logger.LogError("Machine delete failed for {MachineId}: {Detail}", _machineId, detail);
|
|
return;
|
|
}
|
|
|
|
var removed = await _monitor.RemoveMachineAsync(_machineId, CancellationToken.None).ConfigureAwait(true);
|
|
if (!removed.IsSuccess)
|
|
{
|
|
var detail = removed.Errors.Count > 0 ? removed.Errors[0].Message : "unknown error";
|
|
_logger.LogWarning("Live remove failed for {MachineId}; machine deleted from store: {Detail}", _machineId, detail);
|
|
}
|
|
|
|
_logger.LogInformation("Machine {MachineId} deleted via detail screen.", _machineId);
|
|
|
|
Dispose();
|
|
if (Navigator != null)
|
|
{
|
|
await Navigator.GoToDashboardAndReloadAsync().ConfigureAwait(true);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
if (_subscribed)
|
|
{
|
|
_monitor.SnapshotUpdated -= OnSnapshotUpdated;
|
|
_subscribed = false;
|
|
}
|
|
}
|
|
}
|
|
}
|