refactor: v0.2.1 refinements — HttpClient lifetime, disconnection UX, DataGrid

Post-v0.2 polish (no behavior regressions; 127 tests + 2 docker integration green).

- MTConnect: shared static HttpClient (Timeout=Infinite) + per-request timeout
  via linked CancellationTokenSource. Fixes socket-exhaustion risk from
  new-HttpClient-per-driver on config edits / dynamic monitor reload.
- Disconnection UX: dashboard + detail show connection state with color
  (green/red/grey dot + badge) via StatusKindToBrush converter; LastSeen
  timestamp preserved across disconnects. VMs stay framework-agnostic.
- Delete confirm: VM-driven overlay naming the machine (replaces two-state
  button) to prevent accidental deletion.
- Dashboard table: switched to DataGrid — aligned columns + Details gets its
  own column (fixes row misalignment). Adds Avalonia.Controls.DataGrid 11.3.10.

PAUL: roadmap re-sequenced — v0.3 Data-Item Selection + UX, OPC UA → v0.4,
Fanuc → v0.5, History → v0.6.

Touches: Junction.Protocols.MTConnect, Junction.App, tests, .paul/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dtrentin 2026-07-22 00:09:48 +02:00
parent 6f75f7feb1
commit cf394bab31
13 changed files with 396 additions and 98 deletions

View file

@ -5,10 +5,11 @@
| Version | Name | Phases | Status | Completed |
|---------|------|--------|--------|-----------|
| v0.1 | Walking Skeleton (MTConnect) | 1 | ✅ Shipped | 2026-07-21 |
| v0.2 | Machine Management UX | 2 | 🚧 Complete (commit pending) | - |
| v0.3 | OPC UA Protocol | 3 | 📋 Planned | - |
| v0.4 | Fanuc FOCAS Protocol | 4 | 📋 Planned | - |
| v0.5 | History & Trends | 5 | 📋 Planned | - |
| v0.2 | Machine Management UX | 2, 2.1 | ✅ Shipped (2.1 commit pending) | 2026-07-21 |
| v0.3 | Data-Item Selection + UX | 3, 3.1 | 📋 Planned | - |
| v0.4 | OPC UA Protocol | 4 | 📋 Planned | - |
| v0.5 | Fanuc FOCAS Protocol | 5 | 📋 Planned | - |
| v0.6 | History & Trends | 6 | 📋 Planned | - |
## 🚧 Active Milestone: v0.2 Machine Management UX
@ -27,35 +28,57 @@
- [x] 02-B: App — machine detail screen (full snapshot) + dashboard row-click nav + back
- [x] 02-C: App — config add/edit/delete screen + repo.Upsert + live monitor reload; Core AvailableProtocols
## 📋 Planned Milestone: v0.3 OPC UA Protocol
### Phase 2.1: Refinements [INSERTED]
**Goal:** Second protocol plugin (OPC UA) proving the multi-protocol architecture.
**Prerequisite:** v0.2 complete
**Estimated phases:** 1
**Goal:** Post-v0.2 polish + repo hygiene.
**Depends on:** Phase 2
**Reason:** UX/robustness feedback + tracked-artifact cleanup.
**Plans:**
- [x] 02.1-R1: MTConnect shared static HttpClient + per-request timeout (linked CTS) — fix socket-exhaustion
- [x] 02.1-R2R3: disconnection UX (status color/dot/badge + LastSeen) + robust delete confirm overlay
- [x] 02.1-tablefix: dashboard → DataGrid (aligned columns, Details own column) + Avalonia.Controls.DataGrid 11.3.10
## 📋 Planned Milestone: v0.3 Data-Item Selection + UX
**Goal:** Per-machine selection of which data items to monitor (probe catalog, opt-in, filters persistence); then themed/iconed UI polish.
**Prerequisite:** v0.2.1 committed
**Estimated phases:** 2
| Phase | Focus | Research |
|-------|-------|----------|
| 3 | OPC UA driver plugin (endpoint/nodes → MachineSnapshot), config keys, tests | Likely (OPC UA SDK on net48) |
| 3 | Item selection: Domain Machine.MonitoredItemIds + DataItemDescriptor + IProtocolDriver.ProbeAsync; MTConnect probe + filter to selected; Persistence col; App config checklist + detail shows selected | Unlikely (probe parser exists) |
| 3.1 | Theming/UX: Fluent light/dark + accent, per-protocol icons, curated layout | Unlikely |
## 📋 Planned Milestone: v0.4 Fanuc FOCAS Protocol
## 📋 Planned Milestone: v0.4 OPC UA Protocol
**Goal:** Fanuc FOCAS plugin via native fwlib32 (P/Invoke, Windows-only).
**Goal:** Second protocol plugin (OPC UA) proving multi-protocol architecture.
**Prerequisite:** v0.3 complete
**Estimated phases:** 1
| Phase | Focus | Research |
|-------|-------|----------|
| 4 | FOCAS P/Invoke driver, native lib lifetime/marshalling, Windows-only guard | Likely (native interop; use dotnet:dotnet-pinvoke) |
| 4 | OPC UA driver plugin (endpoint/nodes → MachineSnapshot), config keys, tests | Likely (OPC UA SDK on net48) |
## 📋 Planned Milestone: v0.5 History & Trends
## 📋 Planned Milestone: v0.5 Fanuc FOCAS Protocol
**Goal:** Persist time-series, show trends in detail page.
**Prerequisite:** v0.4 (or parallelizable)
**Goal:** Fanuc FOCAS plugin via native fwlib32 (P/Invoke, Windows-only).
**Prerequisite:** v0.4 complete
**Estimated phases:** 1
| Phase | Focus | Research |
|-------|-------|----------|
| 5 | History tables, retention, trend charts in detail view | Unlikely |
| 5 | FOCAS P/Invoke driver, native lib lifetime/marshalling, Windows-only guard | Likely (native interop; use dotnet:dotnet-pinvoke) |
## 📋 Planned Milestone: v0.6 History & Trends
**Goal:** Persist time-series, show trends in detail page.
**Prerequisite:** v0.5 (or parallelizable)
**Estimated phases:** 1
| Phase | Focus | Research |
|-------|-------|----------|
| 6 | History tables, retention, trend charts in detail view | Unlikely |
## ✅ Completed Milestones

View file

@ -5,27 +5,31 @@
See: .paul/PROJECT.md (updated 2026-07-21)
**Core value:** Operators see live state of every configured machine across heterogeneous protocols in one place, adding machines/protocols without code.
**Current focus:** v0.2 complete (commit pending) → next v0.3 OPC UA
**Current focus:** v0.2 + v0.2.1 shipped → starting v0.3 Data-Item Selection
## Current Position
Milestone: v0.2 Machine Management UX
Phase: 2 of 2 (Machine Management UX) — COMPLETE
Plan: 02-A ✓ 02-B ✓ 02-C
Status: Complete (commit pending) — build 0/0, 125 tests + 2 docker-skip
Last activity: 2026-07-21 — M2 done: detail screen, config CRUD, offline detection, dynamic monitor reload. 3 screens live.
Milestone: v0.2 Machine Management UX (+ 2.1 refinements) — SHIPPED
Phase: 2 ✓, 2.1 ✓ → next Phase 3 (Data-Item Selection)
Plan: 02-A/B/C ✓, 02.1 R1/R2R3/tablefix
Status: v0.2.1 committing; v0.3 backend next
Last activity: 2026-07-22 — v0.2.1 done: shared HttpClient, disconnection UX, delete-confirm overlay, dashboard DataGrid (aligned + Details column). Build 0/0, 127 tests + 2 docker-skip.
Progress:
- Milestone v0.2: [██████████] 100%
- Phase 2: 3 of 3 plans done
- v0.2 (+2.1): [██████████] 100%
- Next: Phase 3 Data-Item Selection
## Loop Position
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ◉ [Unifying — verified; awaiting commit]
✓ ✓ ✓ [v0.2.1 complete → PLAN Phase 3]
```
## Standing Authorization
Auto-commit + push GREEN chunks (build 0-err + tests pass) to origin/main WITHOUT asking (user-granted 2026-07-22). Never commit red; no force-push; artifact leak-check each time.
## Accumulated Context
### Decisions

View file

@ -1,10 +1,10 @@
{
"name": "Junction",
"version": "0.1.0",
"version": "0.2.0",
"milestone": {
"name": "Machine Management UX",
"version": "0.2.0",
"status": "complete_commit_pending"
"status": "shipped"
},
"phase": {
"number": 2,

View file

@ -1,11 +1,16 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Junction.App"
xmlns:conv="clr-namespace:Junction.App.Converters"
x:Class="Junction.App.App">
<Application.Resources>
<conv:StatusKindToBrushConverter x:Key="StatusKindToBrush" />
</Application.Resources>
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
</Application.Styles>
</Application>

View file

@ -0,0 +1,37 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace Junction.App.Converters
{
/// <summary>
/// Maps a <see cref="ViewModels.MachineRowViewModel.StatusKind"/> token
/// ("online"/"offline"/"unknown") to a status brush. Keeps view-models free of
/// UI-framework types: the color decision lives entirely in the view layer.
/// </summary>
public sealed class StatusKindToBrushConverter : IValueConverter
{
public static readonly StatusKindToBrushConverter Instance = new StatusKindToBrushConverter();
private static readonly IBrush Online = new SolidColorBrush(Color.FromRgb(0x2E, 0x7D, 0x32)); // green
private static readonly IBrush Offline = new SolidColorBrush(Color.FromRgb(0xC6, 0x28, 0x28)); // red
private static readonly IBrush Unknown = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // grey
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
switch (value as string)
{
case "online":
return Online;
case "offline":
return Offline;
default:
return Unknown;
}
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
}

View file

@ -14,6 +14,7 @@
<PackageReference Include="Avalonia" Version="11.3.10" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.10" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10" />
<!-- Dev tooling only: not shipped to net48 fleet. -->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.10"

View file

@ -33,21 +33,58 @@ namespace Junction.App.ViewModels
/// <summary>Shell reference used by <see cref="BackCommand"/> to return to the dashboard.</summary>
public MainWindowViewModel? Navigator { get; set; }
[ObservableProperty] private string _machineName = "—";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DeleteConfirmPrompt))]
private string _machineName = "—";
[ObservableProperty] private string _protocolId = "—";
[ObservableProperty] private string _machineIdText = "—";
[ObservableProperty] private string _pollInterval = "—";
[ObservableProperty] private string _agentUrl = "—";
[ObservableProperty] private ConnectionState _connectionState = ConnectionState.Unknown;
[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] private int _itemCount;
/// <summary>Two-state delete guard: first click arms, second click confirms.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DeleteButtonText))]
private bool _confirmingDelete;
/// <summary>True only when currently connected.</summary>
public bool IsOnline => ConnectionState == ConnectionState.Connected;
public string DeleteButtonText => ConfirmingDelete ? "Confirm delete?" : "Delete";
/// <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; } =
@ -129,6 +166,15 @@ namespace Junction.App.ViewModels
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++)
{
@ -137,6 +183,7 @@ namespace Junction.App.ViewModels
ItemCount = Items.Count;
}
}
private void OnSnapshotUpdated(object? sender, MachineSnapshot snapshot)
{
@ -172,25 +219,28 @@ namespace Junction.App.ViewModels
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>
/// Two-state delete: the first invocation arms the confirm state; the second performs the
/// delete (repository + live monitor removal) and returns to the reloaded dashboard.
/// 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 Delete()
private async Task ConfirmDelete()
{
if (!ConfirmingDelete)
{
ConfirmingDelete = true;
return;
}
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);
ConfirmingDelete = false;
return;
}

View file

@ -17,10 +17,42 @@ namespace Junction.App.ViewModels
[ObservableProperty] private string _name;
[ObservableProperty] private string _protocolId;
[ObservableProperty] private ConnectionState _connectionState;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOnline))]
[NotifyPropertyChangedFor(nameof(StatusKind))]
private ConnectionState _connectionState;
[ObservableProperty] private string _lastDatum;
[ObservableProperty] private string _lastUpdated;
/// <summary>Wall-clock time (HH:mm:ss) of the last CONNECTED snapshot; "—" until first connect.</summary>
[ObservableProperty] private string _lastSeen;
/// <summary>True only when currently connected.</summary>
public bool IsOnline => ConnectionState == ConnectionState.Connected;
/// <summary>
/// UI-framework-agnostic status token the View maps to a color:
/// "online" (green), "offline" (red), "unknown" (grey).
/// </summary>
public string StatusKind
{
get
{
switch (ConnectionState)
{
case ConnectionState.Connected:
return "online";
case ConnectionState.Disconnected:
case ConnectionState.Error:
return "offline";
default:
return "unknown";
}
}
}
public MachineRowViewModel(Machine machine, Action<MachineRowViewModel>? onOpenDetail = null)
{
_onOpenDetail = onOpenDetail;
@ -30,6 +62,7 @@ namespace Junction.App.ViewModels
_connectionState = ConnectionState.Unknown;
_lastDatum = "—";
_lastUpdated = "—";
_lastSeen = "—";
}
/// <summary>Opens the detail screen for this machine via the dashboard-supplied callback.</summary>
@ -46,8 +79,20 @@ namespace Junction.App.ViewModels
ConnectionState = snapshot.ConnectionState;
LastUpdated = snapshot.CapturedAt.LocalDateTime.ToString("HH:mm:ss");
if (snapshot.ConnectionState == ConnectionState.Connected)
{
// A live read: advance the last-seen marker and refresh the shown datum.
LastSeen = snapshot.CapturedAt.LocalDateTime.ToString("HH:mm:ss");
}
// Synthetic Disconnected snapshots carry no items; keep the last-known datum
// rather than blanking it, and leave LastSeen at the last connected time.
if (snapshot.Items.Count > 0)
{
LastDatum = Representative(snapshot);
}
}
/// <summary>
/// Picks a human-meaningful datum to show: prefer an availability/execution item,

View file

@ -20,38 +20,54 @@
VerticalAlignment="Center" />
</Grid>
<Border DockPanel.Dock="Top"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
Padding="0,0,0,6" Margin="0,0,0,4">
<Grid ColumnDefinitions="2*,1.2*,3*,1.2*,Auto">
<TextBlock Grid.Column="0" Text="Name" FontWeight="Bold" />
<TextBlock Grid.Column="1" Text="Connection" FontWeight="Bold" />
<TextBlock Grid.Column="2" Text="Last Datum" FontWeight="Bold" />
<TextBlock Grid.Column="3" Text="Updated" FontWeight="Bold" />
<TextBlock Grid.Column="4" Text="" FontWeight="Bold" />
</Grid>
</Border>
<DataGrid ItemsSource="{Binding Machines}"
AutoGenerateColumns="False"
IsReadOnly="True"
CanUserReorderColumns="False"
CanUserResizeColumns="True"
GridLinesVisibility="Horizontal"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTextColumn Header="Machine"
Binding="{Binding Name}"
Width="2*"
x:DataType="vm:MachineRowViewModel" />
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Machines}">
<ItemsControl.ItemTemplate>
<DataGridTemplateColumn Header="Status" Width="Auto">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:MachineRowViewModel">
<Grid ColumnDefinitions="2*,1.2*,3*,1.2*,Auto" Margin="0,6">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
<TextBlock Text="{Binding ProtocolId}" FontSize="11" Opacity="0.6" />
<StackPanel Orientation="Horizontal" Spacing="6"
VerticalAlignment="Center" Margin="8,0">
<Ellipse Width="10" Height="10" VerticalAlignment="Center"
Fill="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}" />
<TextBlock Text="{Binding ConnectionState}"
Foreground="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding ConnectionState}" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding LastDatum}" VerticalAlignment="Center" TextWrapping="Wrap" />
<TextBlock Grid.Column="3" Text="{Binding LastUpdated}" VerticalAlignment="Center" />
<Button Grid.Column="4" Content="Details"
Command="{Binding OpenDetailCommand}"
VerticalAlignment="Center" Margin="8,0,0,0" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Last Datum"
Binding="{Binding LastDatum}"
Width="2*"
x:DataType="vm:MachineRowViewModel" />
<DataGridTextColumn Header="Last Seen"
Binding="{Binding LastSeen}"
Width="Auto"
x:DataType="vm:MachineRowViewModel" />
<DataGridTemplateColumn Header="" Width="Auto">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:MachineRowViewModel">
<Button Content="Details"
Command="{Binding OpenDetailCommand}"
VerticalAlignment="Center" Margin="8,2" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

View file

@ -8,17 +8,23 @@
x:DataType="vm:MachineDetailViewModel"
x:CompileBindings="True">
<Grid>
<DockPanel Margin="16">
<!-- Header bar: Back + machine name + Edit/Delete -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto,Auto" Margin="0,0,0,12">
<!-- Header bar: Back + machine name + status badge + Edit/Delete -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,Auto,*,Auto,Auto" Margin="0,0,0,12">
<Button Grid.Column="0" Content="← Back" Command="{Binding BackCommand}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding MachineName}"
FontSize="22" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="12,0" />
<Button Grid.Column="2" Content="Edit" Command="{Binding EditCommand}"
<Border Grid.Column="2"
Background="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
CornerRadius="4" Padding="8,3" HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding ConnectionState}" Foreground="White" FontWeight="SemiBold" FontSize="12" />
</Border>
<Button Grid.Column="3" Content="Edit" Command="{Binding EditCommand}"
VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Grid.Column="3" Content="{Binding DeleteButtonText}" Command="{Binding DeleteCommand}"
<Button Grid.Column="4" Content="Delete" Command="{Binding RequestDeleteCommand}"
VerticalAlignment="Center" />
</Grid>
@ -28,22 +34,28 @@
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
CornerRadius="4"
Padding="12" Margin="0,0,0,12">
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto" >
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto,Auto" >
<TextBlock Grid.Row="0" Grid.Column="0" Text="Protocol" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ProtocolId}" Margin="0,0,24,4" />
<TextBlock Grid.Row="0" Grid.Column="2" Text="Poll Interval" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="{Binding PollInterval}" Margin="0,0,0,4" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Connection" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ConnectionState}" Margin="0,0,24,4" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ConnectionState}"
Foreground="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
FontWeight="SemiBold" Margin="0,0,24,4" />
<TextBlock Grid.Row="1" Grid.Column="2" Text="Captured At" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="1" Grid.Column="3" Text="{Binding CapturedAt}" Margin="0,0,0,4" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Agent URL" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding AgentUrl}" Margin="0,0,24,4" TextWrapping="Wrap" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Last Seen" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding LastSeen}" Margin="0,0,24,4" />
<TextBlock Grid.Row="2" Grid.Column="2" Text="Machine Id" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="2" Grid.Column="3" Text="{Binding MachineIdText}" Margin="0,0,0,4"
FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Agent URL" FontWeight="Bold" Margin="0,0,12,0" />
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="3" Text="{Binding AgentUrl}"
Margin="0,0,0,0" TextWrapping="Wrap" />
</Grid>
</Border>
@ -83,4 +95,26 @@
</ItemsControl>
</ScrollViewer>
</DockPanel>
<!-- Delete-confirmation overlay: dims the screen, names the machine, requires an explicit choice. -->
<Border IsVisible="{Binding IsDeleteConfirmVisible}"
Background="#99000000">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1" CornerRadius="6"
Padding="20" MaxWidth="420"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="14">
<TextBlock Text="{Binding DeleteConfirmPrompt}" FontSize="16" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Text="This permanently removes the machine and stops monitoring it. This cannot be undone."
Opacity="0.75" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Cancel" Command="{Binding CancelDeleteCommand}" />
<Button Content="Delete" Command="{Binding ConfirmDeleteCommand}"
Background="#C62828" Foreground="White" FontWeight="SemiBold" />
</StackPanel>
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>

View file

@ -29,6 +29,7 @@ namespace Junction.Protocols.MTConnect
private readonly Guid _machineId;
private readonly Uri _currentUri;
private readonly HttpClient _http;
private readonly TimeSpan _requestTimeout;
/// <summary>Protocol identifier this driver serves.</summary>
public string ProtocolId => "mtconnect";
@ -38,11 +39,24 @@ namespace Junction.Protocols.MTConnect
/// </summary>
/// <param name="machineId">Machine this driver reads for; stamped onto the snapshot.</param>
/// <param name="agentUrl">Base MTConnect agent URL (e.g. "http://host:5000"). Non-null, absolute.</param>
/// <param name="httpClient">HTTP transport. Injected for testability; owned by the caller/factory.</param>
public MtconnectDriver(Guid machineId, string agentUrl, HttpClient httpClient)
/// <param name="httpClient">
/// HTTP transport. Injected for testability; owned by the caller/factory. In production a single
/// shared instance is reused across all drivers (its global <see cref="HttpClient.Timeout"/> is
/// <see cref="Timeout.InfiniteTimeSpan"/>), so the per-request timeout is enforced here.
/// </param>
/// <param name="requestTimeout">
/// Per-request timeout enforced via a linked <see cref="CancellationTokenSource"/>. Must be positive
/// or <see cref="Timeout.InfiniteTimeSpan"/>.
/// </param>
public MtconnectDriver(Guid machineId, string agentUrl, HttpClient httpClient, TimeSpan requestTimeout)
{
if (agentUrl is null) throw new ArgumentNullException(nameof(agentUrl));
_http = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
if (requestTimeout <= TimeSpan.Zero && requestTimeout != Timeout.InfiniteTimeSpan)
{
throw new ArgumentOutOfRangeException(nameof(requestTimeout), requestTimeout, "Request timeout must be positive or Timeout.InfiniteTimeSpan.");
}
_requestTimeout = requestTimeout;
_machineId = machineId;
if (!Uri.TryCreate(agentUrl, UriKind.Absolute, out var baseUri))
@ -67,10 +81,16 @@ namespace Junction.Protocols.MTConnect
return Result<MachineSnapshot>.Cancelled();
}
// Shared HttpClient has an infinite global timeout; enforce the per-request timeout via a
// linked CTS. Caller cancel and timeout both surface as OperationCanceledException on the
// linked token; they are told apart by inspecting the caller token.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_requestTimeout);
HttpResponseMessage response;
try
{
response = await _http.GetAsync(_currentUri, cancellationToken).ConfigureAwait(false);
response = await _http.GetAsync(_currentUri, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@ -79,7 +99,7 @@ namespace Junction.Protocols.MTConnect
}
catch (OperationCanceledException ex)
{
// TaskCanceledException not tied to the caller token => client timeout.
// Cancelled by the linked CTS but not by the caller => per-request timeout.
return Fail("CURRENT_TIMEOUT", "HTTP request to '" + _currentUri + "' timed out: " + ex.Message);
}
catch (HttpRequestException ex)
@ -100,7 +120,7 @@ namespace Junction.Protocols.MTConnect
try
{
#if NET5_0_OR_GREATER
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
body = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
#else
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
@ -109,6 +129,10 @@ namespace Junction.Protocols.MTConnect
{
return Result<MachineSnapshot>.Cancelled();
}
catch (OperationCanceledException ex)
{
return Fail("CURRENT_TIMEOUT", "Reading response body from '" + _currentUri + "' timed out: " + ex.Message);
}
catch (HttpRequestException ex)
{
return Fail("CURRENT_HTTP_ERROR", "Reading response body from '" + _currentUri + "' failed: " + ex.Message);

View file

@ -12,8 +12,11 @@ namespace Junction.Protocols.MTConnect
/// <see cref="Activator.CreateInstance(Type)"/>, so it MUST have a public parameterless
/// constructor.
/// <para>
/// Owns MTConnect-specific config validation and builds a real <see cref="HttpClient"/>
/// for the produced <see cref="MtconnectDriver"/>, keeping the Domain protocol-agnostic.
/// Owns MTConnect-specific config validation and hands each produced <see cref="MtconnectDriver"/>
/// a single shared <see cref="HttpClient"/>, keeping the Domain protocol-agnostic. Drivers are
/// recreated on config edits (AddOrUpdateMachineAsync); a per-driver client would risk socket
/// exhaustion, so the client is a process-wide singleton with an infinite global timeout and the
/// per-machine timeout is enforced inside the driver.
/// </para>
/// <para>Expected <see cref="Machine.ConnectionConfig"/> keys (case-insensitive):</para>
/// <list type="bullet">
@ -28,6 +31,14 @@ namespace Junction.Protocols.MTConnect
private const string TimeoutKey = "TimeoutSeconds";
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
/// <summary>
/// Process-wide shared transport reused by every driver this factory creates. Its global
/// <see cref="HttpClient.Timeout"/> is infinite; each driver enforces its own per-request
/// timeout via a linked <see cref="System.Threading.CancellationTokenSource"/>. Sharing one
/// client avoids socket exhaustion when drivers are recreated on config edits.
/// </summary>
private static readonly HttpClient SharedHttpClient = new HttpClient { Timeout = System.Threading.Timeout.InfiniteTimeSpan };
/// <summary>Protocol identifier this factory produces drivers for.</summary>
public string ProtocolId => "mtconnect";
@ -75,8 +86,7 @@ namespace Junction.Protocols.MTConnect
timeout = TimeSpan.FromSeconds(seconds);
}
var http = new HttpClient { Timeout = timeout };
var driver = new MtconnectDriver(machine.Id, agentUrl, http);
var driver = new MtconnectDriver(machine.Id, agentUrl, SharedHttpClient, timeout);
return Result<IProtocolDriver>.Ok(driver);
}

View file

@ -50,8 +50,15 @@ namespace Junction.Tests.Unit
}
}
private static MtconnectDriver DriverWith(HttpMessageHandler handler) =>
new MtconnectDriver(MachineId, AgentUrl, new HttpClient(handler));
private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30);
// Shared client with infinite global timeout mirrors production; per-request timeout enforced in driver.
private static MtconnectDriver DriverWith(HttpMessageHandler handler, TimeSpan? requestTimeout = null) =>
new MtconnectDriver(
MachineId,
AgentUrl,
new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan },
requestTimeout ?? GenerousTimeout);
// ---- ReadCurrentAsync: happy path ----
@ -150,6 +157,48 @@ namespace Junction.Tests.Unit
Assert.True(result.WasCancelled);
}
// ---- ReadCurrentAsync: per-request timeout (linked CTS) ----
[Fact]
public async Task ReadCurrentAsync_RequestExceedsPerRequestTimeout_ReturnsFailCurrentTimeoutNotCancelled()
{
// Handler stalls until its token trips; only the driver's linked CTS (CancelAfter) can trip it.
var handler = new StubHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK);
});
var driver = DriverWith(handler, TimeSpan.FromMilliseconds(50));
// Caller token never cancelled: any cancellation here is the per-request timeout.
var result = await driver.ReadCurrentAsync(CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.False(result.WasCancelled);
Assert.NotEmpty(result.Errors);
Assert.Contains(result.Errors, e => e.Code == "CURRENT_TIMEOUT");
}
[Fact]
public async Task ReadCurrentAsync_CallerCancelBeatsTimeout_ReturnsCancelled()
{
var handler = new StubHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK);
});
// Generous per-request timeout so the caller cancel wins the race, not the timeout.
var driver = DriverWith(handler, GenerousTimeout);
using var cts = new CancellationTokenSource();
var task = driver.ReadCurrentAsync(cts.Token);
cts.Cancel();
var result = await task;
Assert.False(result.IsSuccess);
Assert.True(result.WasCancelled);
}
// ---- Factory: config validation ----
private static Machine MachineWithConfig(IReadOnlyDictionary<string, string>? config) =>