junction/src/Junction.App/ViewModels/MachineDetailViewModel.cs
dtrentin 54f8b3be25 feat: history & trends (v0.6)
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>
2026-07-22 08:57:52 +02:00

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;
}
}
}
}