feat(stats): wire IsoHealthStats end-to-end and surface live counters in UI

IsoPipeline now publishes refs to its currently-live NdiReceiver and NdiSender (set by RunInnerPipelineAsync, cleared on exit) so a stats poll from any thread can read FramesCaptured / FramesSent without entangling the pipeline's lifetime with its observer. The receiver's raw-frame channel is wrapped with a TappedChannelWriter so the most recent RawFrame is captured for source-resolution display, again without changing the receiver's contract.

IsoController.GetStats() drops the stub return-Empty and instead reads the live pipeline.GetStats() outside the gate so a slow stats read can't serialize the controller's other operations.

WPF: MainViewModel runs a 1 Hz DispatcherTimer that pulls stats for every participant view-model and pushes them via UpdateStats(). ParticipantViewModel grows three displayable properties — FramesIn, FramesOut, IncomingResolution — bound into the participants DataGrid as a new 'Live' column showing the down/up frame counts and the source resolution underneath the machine name.

Tests: 74/74 unit + 9/9 NDI integration green; the existing round-trip integration test exercises the new wiring at runtime (live receiver/sender refs are set, frames flow, channels close cleanly).
This commit is contained in:
Zac Gaetano 2026-05-08 00:52:44 -04:00
parent f07aad1c6a
commit 9c231118de
5 changed files with 187 additions and 11 deletions

View file

@ -276,9 +276,34 @@
<DataGridTemplateColumn Header="Source" Width="*"> <DataGridTemplateColumn Header="Source" Width="*">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding SourceMachine}" <StackPanel VerticalAlignment="Center">
Style="{StaticResource Wd.Text.Mono}" <TextBlock Text="{Binding SourceMachine}"
VerticalAlignment="Center"/> Style="{StaticResource Wd.Text.Mono}"/>
<TextBlock Text="{Binding IncomingResolution}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Live" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="11">
<Run Text="↓ "/>
<Run Text="{Binding FramesIn}"/>
</TextBlock>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Tertiary}">
<Run Text="↑ "/>
<Run Text="{Binding FramesOut}"/>
</TextBlock>
</StackPanel>
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>

View file

@ -18,6 +18,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
private readonly Dispatcher _dispatcher; private readonly Dispatcher _dispatcher;
private readonly IDisposable _participantsSub; private readonly IDisposable _participantsSub;
private readonly IDisposable _alertsSub; private readonly IDisposable _alertsSub;
private readonly DispatcherTimer _statsTimer;
private readonly Dictionary<Guid, ParticipantViewModel> _byId = new(); private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
private string _statusText = "Starting…"; private string _statusText = "Starting…";
@ -49,6 +50,33 @@ public sealed class MainViewModel : ObservableObject, IDisposable
{ {
AlertBanner.Current = alert; AlertBanner.Current = alert;
}); });
// 1 Hz stats poll — pull live frame counters from each running pipeline and
// push them onto the per-participant view models. Cheap (just reads volatile
// fields on the engine side) and runs on the UI dispatcher so SetField is safe.
_statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
{
Interval = TimeSpan.FromSeconds(1),
};
_statsTimer.Tick += OnStatsTick;
_statsTimer.Start();
}
private void OnStatsTick(object? sender, EventArgs e)
{
foreach (var vm in Participants)
{
try
{
var stats = _controller.GetStats(vm.Id);
vm.UpdateStats(stats);
}
catch
{
// Stats are advisory; never let a transient read failure
// tear down the timer or surface an error to the user.
}
}
} }
public async Task InitializeAsync(CancellationToken cancellationToken) public async Task InitializeAsync(CancellationToken cancellationToken)
@ -99,6 +127,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
public void Dispose() public void Dispose()
{ {
_statsTimer.Stop();
_statsTimer.Tick -= OnStatsTick;
_participantsSub.Dispose(); _participantsSub.Dispose();
_alertsSub.Dispose(); _alertsSub.Dispose();
} }

View file

@ -1,5 +1,6 @@
using TeamsISO.Engine.Controller; using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain; using TeamsISO.Engine.Domain;
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;
namespace TeamsISO.App.ViewModels; namespace TeamsISO.App.ViewModels;
@ -35,6 +36,29 @@ public sealed class ParticipantViewModel : ObservableObject
set => SetField(ref _isEnabled, value); set => SetField(ref _isEnabled, value);
} }
private long _framesIn;
private long _framesOut;
private string _incomingResolution = "—";
/// <summary>Number of frames the receiver has captured so far.</summary>
public long FramesIn { get => _framesIn; private set => SetField(ref _framesIn, value); }
/// <summary>Number of frames the sender has emitted so far.</summary>
public long FramesOut { get => _framesOut; private set => SetField(ref _framesOut, value); }
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
public string IncomingResolution { get => _incomingResolution; private set => SetField(ref _incomingResolution, value); }
/// <summary>Updates the live stats display from a controller-side snapshot.</summary>
public void UpdateStats(IsoHealthStats stats)
{
FramesIn = stats.FramesIn;
FramesOut = stats.FramesOut;
IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
: "—";
}
public bool IsProcessing public bool IsProcessing
{ {
get => _isProcessing; get => _isProcessing;

View file

@ -97,12 +97,15 @@ public sealed class IsoController : IIsoController
public IsoHealthStats GetStats(Guid participantId) public IsoHealthStats GetStats(Guid participantId)
{ {
IsoPipeline? pipeline;
lock (_gate) lock (_gate)
{ {
return _pipelines.TryGetValue(participantId, out var pipeline) if (!_pipelines.TryGetValue(participantId, out pipeline))
? IsoHealthStats.Empty // production wires pipeline.Stats; Phase B-1 leaves this stub return IsoHealthStats.Empty;
: IsoHealthStats.Empty;
} }
// GetStats() is thread-safe and fast; pull outside the gate so a slow stats
// read doesn't serialize the controller's other operations.
return pipeline.GetStats();
} }
public async Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken) public async Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken)

View file

@ -11,7 +11,7 @@ namespace TeamsISO.Engine.Pipeline;
/// </summary> /// </summary>
public sealed class IsoPipeline : IAsyncDisposable public sealed class IsoPipeline : IAsyncDisposable
{ {
private readonly Func<CancellationToken, Task> _runInner; private Func<CancellationToken, Task> _runInner;
private readonly ExponentialBackoff _backoff; private readonly ExponentialBackoff _backoff;
private readonly Func<TimeSpan, CancellationToken, Task> _delay; private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly ILogger<IsoPipeline> _logger; private readonly ILogger<IsoPipeline> _logger;
@ -20,10 +20,44 @@ public sealed class IsoPipeline : IAsyncDisposable
private Task? _supervisorTask; private Task? _supervisorTask;
private int _consecutiveFailures; private int _consecutiveFailures;
// Refs to the currently-live receiver and sender, set by the inner loop on each
// restart. Reads via Volatile.Read are safe from any thread (UI's stats poll).
private NdiReceiver? _liveReceiver;
private NdiSender? _liveSender;
private RawFrame? _lastReceivedFrame;
private DateTimeOffset? _lastReceivedAt;
public Guid ParticipantId { get; } public Guid ParticipantId { get; }
public IsoState State { get; private set; } = IsoState.Idle; public IsoState State { get; private set; } = IsoState.Idle;
public int ConsecutiveFailures => _consecutiveFailures; public int ConsecutiveFailures => _consecutiveFailures;
/// <summary>
/// Snapshot of the pipeline's current health. Safe to call from any thread; values
/// are inherently a moment-in-time view and may change immediately. Returns
/// <see cref="Domain.IsoHealthStats.Empty"/> when no inner pipeline is currently
/// running (e.g. between supervisor restarts or after final failure).
/// </summary>
public Domain.IsoHealthStats GetStats()
{
var receiver = Volatile.Read(ref _liveReceiver);
var sender = Volatile.Read(ref _liveSender);
var lastFrame = Volatile.Read(ref _lastReceivedFrame);
var lastAt = _lastReceivedAt;
if (receiver is null || sender is null)
return Domain.IsoHealthStats.Empty;
return new Domain.IsoHealthStats(
FramesIn: receiver.FramesCaptured,
FramesOut: sender.FramesSent,
FramesDropped: 0, // FrameProcessor currently doesn't surface drops; wire later
FramesDuplicated: 0, // same — last-frame re-emits aren't counted yet
LastFrameAt: lastAt,
IncomingFps: 0, // running rate computation is a follow-up
IncomingWidth: lastFrame?.Width ?? 0,
IncomingHeight: lastFrame?.Height ?? 0);
}
/// <summary> /// <summary>
/// Test ctor. The caller supplies the inner runner directly so failures and lifetimes /// Test ctor. The caller supplies the inner runner directly so failures and lifetimes
/// can be controlled from a unit test. /// can be controlled from a unit test.
@ -54,10 +88,32 @@ public sealed class IsoPipeline : IAsyncDisposable
Func<TimeSpan, CancellationToken, Task> delay, Func<TimeSpan, CancellationToken, Task> delay,
ILoggerFactory loggerFactory) ILoggerFactory loggerFactory)
: this(config.ParticipantId, : this(config.ParticipantId,
ct => RunInnerPipelineAsync(config, interop, scaler, frameClock, loggerFactory, ct), // The inner-runner closure captures `this` so the receiver/sender
// wired by RunInnerPipelineAsync can be published to instance fields
// for stats reads.
default(Func<CancellationToken, Task>)!,
backoff, backoff,
delay, delay,
loggerFactory) { } loggerFactory)
{
_runInner = ct => RunInnerPipelineAsync(
config, interop, scaler, frameClock, loggerFactory, ct,
onLive: (recv, send) =>
{
Volatile.Write(ref _liveReceiver, recv);
Volatile.Write(ref _liveSender, send);
},
onClear: () =>
{
Volatile.Write(ref _liveReceiver, null);
Volatile.Write(ref _liveSender, null);
},
onFrame: frame =>
{
Volatile.Write(ref _lastReceivedFrame, frame);
_lastReceivedAt = DateTimeOffset.UtcNow;
});
}
/// <summary>Starts the supervisor. Returns immediately; pipeline runs in the background.</summary> /// <summary>Starts the supervisor. Returns immediately; pipeline runs in the background.</summary>
public Task StartAsync() public Task StartAsync()
@ -139,6 +195,12 @@ public sealed class IsoPipeline : IAsyncDisposable
/// <summary> /// <summary>
/// Default inner pipeline: spins up receiver → processor → sender on bounded channels /// Default inner pipeline: spins up receiver → processor → sender on bounded channels
/// and awaits all three. Throws if any of them throws. /// and awaits all three. Throws if any of them throws.
///
/// The optional <paramref name="onLive"/> / <paramref name="onClear"/> / <paramref name="onFrame"/>
/// callbacks let the outer <see cref="IsoPipeline"/> publish references to the live
/// receiver and sender (so it can read counters from any thread for health stats)
/// and observe the most recent received frame (so source resolution / last-seen-at
/// can be surfaced in the UI). All three are no-ops by default.
/// </summary> /// </summary>
private static async Task RunInnerPipelineAsync( private static async Task RunInnerPipelineAsync(
IsoPipelineConfig config, IsoPipelineConfig config,
@ -146,7 +208,10 @@ public sealed class IsoPipeline : IAsyncDisposable
IFrameScaler scaler, IFrameScaler scaler,
IFrameClock frameClock, IFrameClock frameClock,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
CancellationToken ct) CancellationToken ct,
Action<NdiReceiver, NdiSender>? onLive = null,
Action? onClear = null,
Action<RawFrame>? onFrame = null)
{ {
var rawChannel = Channel.CreateBounded<RawFrame>(new BoundedChannelOptions(config.RawChannelCapacity) var rawChannel = Channel.CreateBounded<RawFrame>(new BoundedChannelOptions(config.RawChannelCapacity)
{ {
@ -161,12 +226,20 @@ public sealed class IsoPipeline : IAsyncDisposable
SingleWriter = true, SingleWriter = true,
}); });
// Tap the raw frames as they flow into the channel so the host can show "last
// frame at" / source resolution without us re-implementing a probe.
var rawWriter = onFrame is null
? rawChannel.Writer
: new TappedChannelWriter<RawFrame>(rawChannel.Writer, onFrame);
using var receiver = new NdiReceiver( using var receiver = new NdiReceiver(
interop, config.SourceName, rawChannel.Writer, loggerFactory.CreateLogger<NdiReceiver>()); interop, config.SourceName, rawWriter, loggerFactory.CreateLogger<NdiReceiver>());
using var sender = new NdiSender( using var sender = new NdiSender(
interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(), interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger<NdiSender>(),
config.OutputGroups); config.OutputGroups);
onLive?.Invoke(receiver, sender);
var processor = new FrameProcessor( var processor = new FrameProcessor(
config.Settings, scaler, new SolidFrameRenderer(), config.Settings, scaler, new SolidFrameRenderer(),
frameClock, rawChannel.Reader, processedChannel.Writer, frameClock, rawChannel.Reader, processedChannel.Writer,
@ -184,9 +257,30 @@ public sealed class IsoPipeline : IAsyncDisposable
{ {
rawChannel.Writer.TryComplete(); rawChannel.Writer.TryComplete();
processedChannel.Writer.TryComplete(); processedChannel.Writer.TryComplete();
onClear?.Invoke();
} }
} }
/// <summary>
/// Channel-writer wrapper that fires a callback on every successful write but
/// otherwise behaves identically to the inner writer. Used to tap the raw-frame
/// stream for stats without entangling the receiver with the stats API.
/// </summary>
private sealed class TappedChannelWriter<T> : ChannelWriter<T>
{
private readonly ChannelWriter<T> _inner;
private readonly Action<T> _onWrite;
public TappedChannelWriter(ChannelWriter<T> inner, Action<T> onWrite) { _inner = inner; _onWrite = onWrite; }
public override bool TryWrite(T item)
{
if (_inner.TryWrite(item)) { _onWrite(item); return true; }
return false;
}
public override ValueTask<bool> WaitToWriteAsync(CancellationToken ct = default)
=> _inner.WaitToWriteAsync(ct);
public override bool TryComplete(Exception? error = null) => _inner.TryComplete(error);
}
private static async Task ProcessorLoopAsync(FrameProcessor processor, IFrameClock clock, CancellationToken ct) private static async Task ProcessorLoopAsync(FrameProcessor processor, IFrameClock clock, CancellationToken ct)
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)