diff --git a/src/Junction.App/Controls/Sparkline.cs b/src/Junction.App/Controls/Sparkline.cs
new file mode 100644
index 0000000..c2edf2f
--- /dev/null
+++ b/src/Junction.App/Controls/Sparkline.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace Junction.App.Controls
+{
+ ///
+ /// Lightweight per-item trend line: draws a numeric time-series (oldest → newest, left → right)
+ /// as a single polyline scaled to fill the control. No axes, no labels — a table-cell sparkline.
+ /// Rendering-only; the parsing/collection of trend values lives in the view-model layer, so this
+ /// control stays a pure with no data-shaping logic to unit-test.
+ /// net48-safe: uses only core Avalonia drawing APIs (StreamGeometry + Pen), no external
+ /// charting dependency. Blank when there is nothing meaningful to draw (< 2 points).
+ ///
+ public sealed class Sparkline : Control
+ {
+ /// Trend samples in ascending time order. < 2 points renders blank.
+ public static readonly StyledProperty?> PointsProperty =
+ AvaloniaProperty.Register?>(nameof(Points));
+
+ /// Line color. Defaults to Junction's accent blue; the view binds the themed brush.
+ public static readonly StyledProperty StrokeProperty =
+ AvaloniaProperty.Register(
+ nameof(Stroke),
+ new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB)));
+
+ static Sparkline()
+ {
+ AffectsRender(PointsProperty, StrokeProperty);
+ AffectsMeasure(PointsProperty);
+ }
+
+ public IReadOnlyList? Points
+ {
+ get => GetValue(PointsProperty);
+ set => SetValue(PointsProperty, value);
+ }
+
+ public IBrush? Stroke
+ {
+ get => GetValue(StrokeProperty);
+ set => SetValue(StrokeProperty, value);
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ // Own no intrinsic width (stretch into the column); pick a sensible default height
+ // when the parent imposes none.
+ double height = double.IsInfinity(availableSize.Height) ? 24 : availableSize.Height;
+ return new Size(0, height);
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ base.Render(context);
+
+ var points = Points;
+ if (points == null || points.Count < 2)
+ {
+ return; // nothing meaningful to draw
+ }
+
+ double width = Bounds.Width;
+ double height = Bounds.Height;
+ if (width <= 0 || height <= 0)
+ {
+ return;
+ }
+
+ double min = points[0];
+ double max = points[0];
+ for (int i = 1; i < points.Count; i++)
+ {
+ double v = points[i];
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ double range = max - min;
+
+ // Small vertical inset so the stroke never clips at the top/bottom edge.
+ const double pad = 1.5;
+ double usableHeight = Math.Max(0, height - (2 * pad));
+ double stepX = width / (points.Count - 1);
+
+ var pen = new Pen(Stroke ?? Brushes.Gray, 1.5)
+ {
+ LineCap = PenLineCap.Round,
+ LineJoin = PenLineJoin.Round
+ };
+
+ var geometry = new StreamGeometry();
+ using (var ctx = geometry.Open())
+ {
+ for (int i = 0; i < points.Count; i++)
+ {
+ double x = i * stepX;
+ // All-equal series → flat mid line; else normalize into [0,1] and invert
+ // so a higher value sits higher on screen.
+ double norm = range > 0 ? (points[i] - min) / range : 0.5;
+ double y = pad + ((1 - norm) * usableHeight);
+
+ var p = new Point(x, y);
+ if (i == 0)
+ {
+ ctx.BeginFigure(p, false);
+ }
+ else
+ {
+ ctx.LineTo(p);
+ }
+ }
+
+ ctx.EndFigure(false);
+ }
+
+ context.DrawGeometry(null, pen, geometry);
+ }
+ }
+}
diff --git a/src/Junction.App/ViewModels/DataItemRowViewModel.cs b/src/Junction.App/ViewModels/DataItemRowViewModel.cs
index 34b6bc2..b2c5119 100644
--- a/src/Junction.App/ViewModels/DataItemRowViewModel.cs
+++ b/src/Junction.App/ViewModels/DataItemRowViewModel.cs
@@ -1,20 +1,33 @@
+using System.Collections.Generic;
+using System.Globalization;
+using CommunityToolkit.Mvvm.ComponentModel;
using Junction.Domain.Models;
namespace Junction.App.ViewModels
{
///
- /// One row of the machine-detail data-item table. Immutable projection of a
- /// ; rebuilt (not mutated) whenever a fresh snapshot arrives,
- /// so it needs no change-notification.
+ /// One row of the machine-detail data-item table. The string fields are a fixed projection of a
+ /// (set in the ctor; rebuilt, not mutated, per snapshot). Observable only
+ /// for , which is populated asynchronously after the row is created (history
+ /// query) and drives the per-item sparkline.
///
- public sealed class DataItemRowViewModel
+ public sealed partial class DataItemRowViewModel : ViewModelBase
{
+ /// History item-id key (matches / snapshot item id).
public string Id { get; }
public string Name { get; }
public string Value { get; }
public string Category { get; }
public string Timestamp { get; }
+ /// Numeric trend samples (ascending time order) for the sparkline; null when none.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasTrend))]
+ private IReadOnlyList? _trend;
+
+ /// True when there are enough numeric samples to draw a trend line.
+ public bool HasTrend => Trend != null && Trend.Count >= 2;
+
public DataItemRowViewModel(DataItem item)
{
Id = item.Id;
@@ -23,5 +36,24 @@ namespace Junction.App.ViewModels
Category = string.IsNullOrWhiteSpace(item.Category) ? "—" : item.Category;
Timestamp = item.Timestamp.LocalDateTime.ToString("HH:mm:ss");
}
+
+ ///
+ /// Culture-invariant numeric parse of a datum value. Trims; returns false for null/blank or
+ /// non-numeric text (e.g. "ON", "AVAILABLE"). Pure and Avalonia-free — unit-testable.
+ ///
+ public static bool TryParseNumeric(string? value, out double result)
+ {
+ result = 0;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ return double.TryParse(
+ value!.Trim(),
+ NumberStyles.Float | NumberStyles.AllowThousands,
+ CultureInfo.InvariantCulture,
+ out result);
+ }
}
}
diff --git a/src/Junction.App/ViewModels/MachineDetailViewModel.cs b/src/Junction.App/ViewModels/MachineDetailViewModel.cs
index 3ef1c2f..cf1236a 100644
--- a/src/Junction.App/ViewModels/MachineDetailViewModel.cs
+++ b/src/Junction.App/ViewModels/MachineDetailViewModel.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Threading;
@@ -23,6 +25,12 @@ namespace Junction.App.ViewModels
///
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;
@@ -30,6 +38,12 @@ namespace Junction.App.ViewModels
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; }
@@ -163,6 +177,7 @@ namespace Junction.App.ViewModels
if (snapshot != null)
{
ApplySnapshot(snapshot);
+ KickTrendLoad();
}
_logger.LogInformation("Machine detail opened for {Name} ({MachineId}); {Count} item(s).",
@@ -210,9 +225,93 @@ namespace Junction.App.ViewModels
}
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()
{
diff --git a/src/Junction.App/Views/MachineDetailView.axaml b/src/Junction.App/Views/MachineDetailView.axaml
index c1b727d..8bf169b 100644
--- a/src/Junction.App/Views/MachineDetailView.axaml
+++ b/src/Junction.App/Views/MachineDetailView.axaml
@@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Junction.App.ViewModels"
+ xmlns:controls="clr-namespace:Junction.App.Controls"
mc:Ignorable="d"
x:Class="Junction.App.Views.MachineDetailView"
x:DataType="vm:MachineDetailViewModel"
@@ -68,11 +69,12 @@
BorderBrush="{DynamicResource CardBorderBrush}"
Padding="0,0,0,6" Margin="0,0,0,4"
IsVisible="{Binding HasItems}">
-
+
+
@@ -82,7 +84,7 @@
-
+
@@ -90,6 +92,9 @@
+
diff --git a/src/Junction.Core/Monitoring/MachineMonitor.cs b/src/Junction.Core/Monitoring/MachineMonitor.cs
index b8f765e..4c4f8bd 100644
--- a/src/Junction.Core/Monitoring/MachineMonitor.cs
+++ b/src/Junction.Core/Monitoring/MachineMonitor.cs
@@ -42,6 +42,15 @@ namespace Junction.Core.Monitoring
private readonly ConcurrentDictionary _snapshots =
new ConcurrentDictionary();
+ // History retention policy (const-fixed for this slice; not config-driven yet).
+ // TimeSpan can't be a C# const, so these are static readonly.
+ private static readonly TimeSpan RetentionWindow = TimeSpan.FromDays(7);
+ private static readonly TimeSpan PruneInterval = TimeSpan.FromHours(1);
+
+ // UTC ticks of the last prune claim. Interlocked-guarded because PersistAsync runs
+ // concurrently across per-machine poll loops. Init to MinValue so the first persist prunes.
+ private long _lastPruneTicks = DateTimeOffset.MinValue.UtcTicks;
+
private readonly object _lifecycleLock = new object();
private readonly List _running = new List();
@@ -462,6 +471,23 @@ namespace Junction.Core.Monitoring
"Failed to persist snapshot for machine {MachineId}: {Errors}",
snapshot.MachineId, DescribeErrors(result.Errors));
}
+
+ // Append time-series history for live reads only. Synthetic Disconnected
+ // snapshots carry no items (AppendHistory no-ops) but gating here avoids the
+ // round-trip and keeps disconnect noise out of the trend. A history failure
+ // must never undo the latest-snapshot save above.
+ if (snapshot.ConnectionState == ConnectionState.Connected)
+ {
+ Result historyResult = await _repository.AppendHistoryAsync(snapshot, cancellationToken).ConfigureAwait(false);
+ if (!historyResult.IsSuccess && !historyResult.WasCancelled)
+ {
+ _logger.LogWarning(
+ "Failed to append history for machine {MachineId}: {Errors}",
+ snapshot.MachineId, DescribeErrors(historyResult.Errors));
+ }
+
+ await MaybePruneHistoryAsync(cancellationToken).ConfigureAwait(false);
+ }
}
catch (OperationCanceledException)
{
@@ -473,6 +499,38 @@ namespace Junction.Core.Monitoring
}
}
+ ///
+ /// Prunes history older than at most once per
+ /// , regardless of poll frequency or machine count. The
+ /// window is claimed via a single Interlocked CompareExchange so concurrent poll loops
+ /// don't all prune at once; a lost race simply skips (harmless).
+ ///
+ private async Task MaybePruneHistoryAsync(CancellationToken cancellationToken)
+ {
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ long lastPrune = Interlocked.Read(ref _lastPruneTicks);
+
+ if (now - new DateTimeOffset(lastPrune, TimeSpan.Zero) < PruneInterval)
+ {
+ return;
+ }
+
+ // Claim the interval; only the loop that wins the swap performs the prune.
+ if (Interlocked.CompareExchange(ref _lastPruneTicks, now.UtcTicks, lastPrune) != lastPrune)
+ {
+ return;
+ }
+
+ DateTimeOffset cutoff = now - RetentionWindow;
+ Result pruneResult = await _repository.PruneHistoryAsync(cutoff, cancellationToken).ConfigureAwait(false);
+ if (!pruneResult.IsSuccess && !pruneResult.WasCancelled)
+ {
+ _logger.LogWarning(
+ "Failed to prune history older than {Cutoff}: {Errors}",
+ cutoff, DescribeErrors(pruneResult.Errors));
+ }
+ }
+
///
/// Maps ProtocolId (case-insensitive) to factory. On duplicate protocol ids the first
/// loaded plugin wins; the collision is logged.
diff --git a/src/Junction.Domain/Models/HistoryPoint.cs b/src/Junction.Domain/Models/HistoryPoint.cs
new file mode 100644
index 0000000..46e80fe
--- /dev/null
+++ b/src/Junction.Domain/Models/HistoryPoint.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace Junction.Domain.Models
+{
+ ///
+ /// One time-series sample of one data item: the value a datum had at a point in time.
+ /// Immutable, protocol-agnostic. Unlike (only-latest), history points
+ /// accumulate append-only so a trend over time can be reconstructed.
+ ///
+ public sealed class HistoryPoint
+ {
+ /// Stable identifier of the datum this sample belongs to (see ).
+ public string ItemId { get; }
+
+ ///
+ /// Sampled value kept as string to stay type-agnostic across protocols;
+ /// callers parse to the concrete type they need.
+ ///
+ public string Value { get; }
+
+ /// Instant the value was reported/observed.
+ public DateTimeOffset Timestamp { get; }
+
+ public HistoryPoint(string itemId, string value, DateTimeOffset timestamp)
+ {
+ ItemId = itemId ?? "";
+ Value = value ?? "";
+ Timestamp = timestamp;
+ }
+ }
+}
diff --git a/src/Junction.Domain/Persistence/IMachineRepository.cs b/src/Junction.Domain/Persistence/IMachineRepository.cs
index 9f1d524..ceb76ea 100644
--- a/src/Junction.Domain/Persistence/IMachineRepository.cs
+++ b/src/Junction.Domain/Persistence/IMachineRepository.cs
@@ -42,5 +42,27 @@ namespace Junction.Domain.Persistence
/// Returns the latest stored snapshot for the machine, or a failed result when none exists.
Task> GetLatestSnapshotAsync(Guid machineId, CancellationToken cancellationToken);
+
+ ///
+ /// Appends one time-series history row per item in (append-only:
+ /// never overwrites an earlier sample). A snapshot with no items is a no-op success.
+ /// This is separate from , which keeps only the latest set.
+ ///
+ Task AppendHistoryAsync(MachineSnapshot snapshot, CancellationToken cancellationToken);
+
+ ///
+ /// Returns time-series samples for a single item of a machine at or after ,
+ /// ascending by timestamp, capped at (keeping the MOST RECENT
+ /// when more exist). Empty list on none — a success, not a failure.
+ /// A non-positive yields an empty success.
+ ///
+ Task>> GetHistoryAsync(Guid machineId, string itemId, DateTimeOffset since, int maxPoints, CancellationToken cancellationToken);
+
+ ///
+ /// Deletes all history rows whose timestamp is strictly older than .
+ /// Idempotent: safe to call when nothing matches. Provides the mechanism only; retention
+ /// policy (deciding the cutoff) lives above this port.
+ ///
+ Task PruneHistoryAsync(DateTimeOffset olderThan, CancellationToken cancellationToken);
}
}
diff --git a/src/Junction.Persistence/SqliteMachineRepository.cs b/src/Junction.Persistence/SqliteMachineRepository.cs
index f23d164..d64c279 100644
--- a/src/Junction.Persistence/SqliteMachineRepository.cs
+++ b/src/Junction.Persistence/SqliteMachineRepository.cs
@@ -189,6 +189,10 @@ namespace Junction.Persistence
{
var idParam = new { Id = GuidText(id) };
+ await connection.ExecuteAsync(new CommandDefinition(
+ "DELETE FROM snapshot_history WHERE MachineId = @Id;",
+ idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
+
await connection.ExecuteAsync(new CommandDefinition(
"DELETE FROM snapshot_items WHERE MachineId = @Id;",
idParam, transaction, cancellationToken: cancellationToken)).ConfigureAwait(false);
@@ -354,6 +358,150 @@ namespace Junction.Persistence
}
}
+ public async Task AppendHistoryAsync(MachineSnapshot snapshot, CancellationToken cancellationToken)
+ {
+ if (snapshot == null)
+ {
+ return Result.Fail(OperationError.Of(Source, "Snapshot is null."));
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return Result.Cancelled();
+ }
+
+ // Append-only: nothing to record for an empty snapshot.
+ if (snapshot.Items.Count == 0)
+ {
+ return Result.Ok();
+ }
+
+ try
+ {
+ using (var connection = OpenConnection())
+ using (var transaction = connection.BeginTransaction())
+ {
+ var machineIdText = GuidText(snapshot.MachineId);
+
+ var itemParams = new List