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 { /// /// 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 /// fires for this machine. Snapshot events /// arrive on poll-loop threads and are marshalled onto the UI thread. /// Created per-navigation (transient); call then /// . is set by the shell to route Back. /// public sealed partial class MachineDetailViewModel : ViewModelBase, IDisposable { /// How far back the per-item trend sparkline looks. private static readonly TimeSpan TrendWindow = TimeSpan.FromHours(1); /// Cap on samples pulled per item for a sparkline (most-recent kept). private const int TrendMaxPoints = 60; private readonly IMachineRepository _repository; private readonly IMachineMonitor _monitor; private readonly ILogger _logger; private Guid _machineId; private bool _subscribed; private bool _disposed; /// /// Handle on the most recently kicked (fire-and-forget) trend load. Production ignores it; /// tests await it to observe the populated values. /// public Task? TrendLoadTask { get; private set; } /// Shell reference used by to return to the dashboard. 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 = "—"; /// Timestamp of the last CONNECTED snapshot; "—" until first connect. [ObservableProperty] private string _lastSeen = "—"; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasItems))] [NotifyPropertyChangedFor(nameof(HasNoItems))] private int _itemCount; /// True when there is at least one monitored data item to display. public bool HasItems => ItemCount > 0; /// True when nothing is monitored — drives the empty-state hint instead of a blank table. public bool HasNoItems => ItemCount == 0; /// True only when currently connected. public bool IsOnline => ConnectionState == ConnectionState.Connected; /// True when the machine is offline (disconnected or errored) — drives the red header badge. public bool IsDisconnected => ConnectionState == ConnectionState.Disconnected || ConnectionState == ConnectionState.Error; /// UI-agnostic status token ("online"/"offline"/"unknown") the View maps to a color. public string StatusKind { get { switch (ConnectionState) { case ConnectionState.Connected: return "online"; case ConnectionState.Disconnected: case ConnectionState.Error: return "offline"; default: return "unknown"; } } } /// Controls visibility of the delete-confirmation overlay. [ObservableProperty] private bool _isDeleteConfirmVisible; /// Named confirmation prompt, e.g. "Delete 'VMC Sim (mock)'?". public string DeleteConfirmPrompt => "Delete '" + MachineName + "'?"; /// The full set of current data items for this machine. public ObservableCollection Items { get; } = new ObservableCollection(); public MachineDetailViewModel( IMachineRepository repository, IMachineMonitor monitor, ILogger logger) { _repository = repository; _monitor = monitor; _logger = logger; } /// Sets the target machine. Call before . public void Initialize(Guid machineId, string machineName) { _machineId = machineId; MachineName = string.IsNullOrWhiteSpace(machineName) ? "—" : machineName; MachineIdText = machineId.ToString(); } /// Loads the machine header + latest snapshot and subscribes to live updates. 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); } /// Projects a snapshot onto the header state + item table. Call on the UI thread. 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(); }); } /// /// 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 /// so its sparkline renders. Rebuilt rows carry no stale trend. A per-item failure never /// aborts the others and never propagates. /// 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(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 trend) { if (Dispatcher.UIThread.CheckAccess()) { row.Trend = trend; } else { Dispatcher.UIThread.Post(() => { if (!_disposed) { row.Trend = trend; } }); } } [RelayCommand] private void Back() { Dispose(); Navigator?.GoToDashboard(); } /// Opens the edit config screen for this machine. [RelayCommand] private void Edit() { Dispose(); Navigator?.ShowConfig(_machineId); } /// Opens the named delete-confirmation overlay. [RelayCommand] private void RequestDelete() => IsDeleteConfirmVisible = true; /// Dismisses the delete-confirmation overlay without deleting. [RelayCommand] private void CancelDelete() => IsDeleteConfirmVisible = false; /// /// Confirmed delete: performs the delete (repository + live monitor removal) and returns to /// the reloaded dashboard. Only reachable from the confirmation overlay. /// [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; } } } }