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:
parent
6f75f7feb1
commit
cf394bab31
13 changed files with 396 additions and 98 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
37
src/Junction.App/Converters/StatusKindToBrushConverter.cs
Normal file
37
src/Junction.App/Converters/StatusKindToBrushConverter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,13 +166,23 @@ namespace Junction.App.ViewModels
|
|||
ConnectionState = snapshot.ConnectionState;
|
||||
CapturedAt = snapshot.CapturedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
Items.Clear();
|
||||
for (int i = 0; i < snapshot.Items.Count; i++)
|
||||
if (snapshot.ConnectionState == ConnectionState.Connected)
|
||||
{
|
||||
Items.Add(new DataItemRowViewModel(snapshot.Items[i]));
|
||||
LastSeen = snapshot.CapturedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
ItemCount = Items.Count;
|
||||
// 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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +79,19 @@ namespace Junction.App.ViewModels
|
|||
|
||||
ConnectionState = snapshot.ConnectionState;
|
||||
LastUpdated = snapshot.CapturedAt.LocalDateTime.ToString("HH:mm:ss");
|
||||
LastDatum = Representative(snapshot);
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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" />
|
||||
<DataGridTemplateColumn Header="Status" Width="Auto">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate x:DataType="vm:MachineRowViewModel">
|
||||
<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"
|
||||
</DataTemplate>
|
||||
</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,0,0,0" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
VerticalAlignment="Center" Margin="8,2" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue