feat: machine management UX + PAUL init (v0.2)
M2 "Machine Management UX": full N-machine management from UI plus runtime resilience. All 3 screens now live (dashboard, detail, config). Core: - PollingEngine offline detection: consecutive-fail threshold emits a synthetic Disconnected snapshot (no-spam, resets on recovery). - MachineMonitor dynamic API: AddOrUpdateMachineAsync / RemoveMachineAsync start/restart/stop a machine's polling live (no app restart); idempotent. - MachineMonitor.AvailableProtocols exposes loaded protocol ids. App: - Machine detail screen: full current snapshot, reachable from dashboard row, live-refreshing, Back nav. - Config screen: add / edit / delete machines with validation; Save upserts + reloads monitor live; Delete two-state confirm + stops polling. - Dashboard: "Add machine" button, per-row Details, reload after mutation. Repo hygiene: - Untrack stray src/Junction.App/plugins/ build artifact (real output goes to bin/*/plugins via build target); add to .gitignore. PAUL: initialized .paul/ (PROJECT/ROADMAP/STATE + paul.json) as cross-session system-of-record. v0.1 shipped, v0.2 complete, v0.3 OPC UA next. Build 0 warn/0 err (net48 + net8.0). Tests: 125 unit + 2 docker integration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
507753f82e
commit
6f75f7feb1
25 changed files with 1664 additions and 22 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -52,3 +52,6 @@ project.fragment.lock.json
|
|||
## OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# stray plugin copy in source tree (real output goes to bin/*/plugins via build target)
|
||||
src/Junction.App/plugins/
|
||||
|
|
|
|||
119
.paul/PROJECT.md
Normal file
119
.paul/PROJECT.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Junction
|
||||
|
||||
## What This Is
|
||||
|
||||
Junction is a Windows desktop application that reads live data from industrial machines over multiple industrial protocols. Protocols are pluggable modules (MTConnect first; OPC UA, Fanuc FOCAS, and others planned). A dashboard lists configured machines with their current status (last read datum), a detail page shows the full current snapshot, and a config screen lets the user add/edit/remove machines.
|
||||
|
||||
## Core Value
|
||||
|
||||
Operators see the live state of every configured machine — across heterogeneous protocols — in one place, adding new machines and protocols without touching code.
|
||||
|
||||
## Current State
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Type | Application (C# / .NET, Avalonia desktop) |
|
||||
| Version | 0.1.0 |
|
||||
| Status | MVP (M1 walking skeleton shipped; M2 in progress) |
|
||||
| Last Updated | 2026-07-21 |
|
||||
|
||||
## Requirements
|
||||
|
||||
### Core Features
|
||||
|
||||
- Dashboard listing configured machines with current connection state + last-read datum, live-refreshing.
|
||||
- Machine detail page: full current snapshot (all data items) reachable from dashboard.
|
||||
- Config screen: add / edit / remove machines (name, protocol, endpoint, poll interval).
|
||||
- Pluggable protocol drivers loaded at runtime (MTConnect implemented).
|
||||
|
||||
### Validated (Shipped)
|
||||
|
||||
- [x] Solution scaffold, multi-project, net48 + net8.0 multi-target — v0.1.0
|
||||
- [x] Domain: Result<T>, models, IProtocolDriver, IMachineRepository, plugin manifest — v0.1.0
|
||||
- [x] Core: PollingEngine, PluginLoader (Assembly.LoadFrom), MachineMonitor, DI — v0.1.0
|
||||
- [x] Persistence: SqliteMachineRepository (Dapper), swap-provider seam — v0.1.0
|
||||
- [x] MTConnect driver + namespace-version-agnostic parser (1.7 + 2.0) — v0.1.0
|
||||
- [x] Avalonia dashboard, live last-datum from monitor — v0.1.0
|
||||
- [x] Docker MTConnect mock (ladder99/agent) — v0.1.0
|
||||
|
||||
### Active (In Progress)
|
||||
|
||||
- [ ] M2: machine detail screen (done), config add/edit/delete (in progress), offline detection (done), live monitor reload on config change (in progress)
|
||||
|
||||
### Planned (Next)
|
||||
|
||||
- OPC UA protocol plugin
|
||||
- Fanuc FOCAS protocol plugin (P/Invoke, Windows-only native fwlib32)
|
||||
- Historical data / trends (schema already allows)
|
||||
- Robust disconnection UX
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- .NET 8+ runtime target (fleet includes Windows 7/8 → net48 mandatory)
|
||||
- EF Core (not net48-compatible at supported versions)
|
||||
|
||||
## Target Users
|
||||
|
||||
**Primary:** Shop-floor operators / production engineers monitoring CNC and industrial machines.
|
||||
- Need at-a-glance machine status across mixed protocol fleets.
|
||||
- Add machines/protocols themselves without a developer.
|
||||
|
||||
## Constraints
|
||||
|
||||
### Technical Constraints
|
||||
|
||||
- Deploy target Windows only; fleet includes Windows 7/8 → **net48 mandatory** (last .NET Framework supporting Win7 SP1).
|
||||
- Avalonia pinned 11.3.x (Avalonia 12 drops net48 — never bump to 12).
|
||||
- Plugins via Assembly.LoadFrom (net48 = no AssemblyLoadContext, no runtime unload).
|
||||
- SQLite native e_sqlite3 needs bundle + RuntimeIdentifiers on net48 (efcore#19396).
|
||||
- Dev machine is Linux → net48 built via Microsoft.NETFramework.ReferenceAssemblies; UI runs on Linux via net8.0 target, ships net48.
|
||||
- DB behind IMachineRepository (swap SQLite → SQL Server).
|
||||
- All package versions pinned exact (deterministic builds for fleet).
|
||||
|
||||
### Business Constraints
|
||||
|
||||
- Manager-mode workflow: all implementation delegated to subagents, reviewed by manager (dotnet-manager skill). Caveman communication.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Date | Status |
|
||||
|----------|-----------|------|--------|
|
||||
| net48 target | Fleet includes Win7/8 | 2026-07-21 | Active |
|
||||
| Avalonia 11.3.10 (never 12) | 12 drops net48 | 2026-07-21 | Active |
|
||||
| Dapper + Microsoft.Data.Sqlite (not EF Core) | EF Core not net48-viable | 2026-07-21 | Active |
|
||||
| Dynamic plugins IProtocolDriver + Assembly.LoadFrom | Add protocols without touching core | 2026-07-21 | Active |
|
||||
| App multi-target net48;net8.0 (dropped separate DevHead) | Dev on Linux net8.0, ship net48, one project | 2026-07-21 | Active |
|
||||
| CommunityToolkit.Mvvm | Lightweight MVVM, net48-safe | 2026-07-21 | Active |
|
||||
| Generic Dictionary machine config | Domain stays protocol-agnostic | 2026-07-21 | Active |
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Target | Current | Status |
|
||||
|--------|--------|---------|--------|
|
||||
| Build (net48 + net8.0, Linux) | 0 errors | 0 err / 0 warn | Achieved |
|
||||
| Test suite | green | 124 pass + 2 docker-skip | On track |
|
||||
| Live e2e (machine → dashboard) | working | proven vs docker mock | Achieved |
|
||||
| Protocols supported | 3+ (MTConnect, OPC, Fanuc) | 1 (MTConnect) | On track |
|
||||
|
||||
## Tech Stack / Tools
|
||||
|
||||
| Layer | Technology | Notes |
|
||||
|-------|------------|-------|
|
||||
| Language/runtime | C# / .NET Framework 4.8 (+ net8.0 dev head) | multi-target |
|
||||
| UI | Avalonia 11.3.10 + CommunityToolkit.Mvvm | MVVM |
|
||||
| DI | Microsoft.Extensions.DependencyInjection 8.0.x | App = composition root |
|
||||
| Persistence | Dapper 2.1.66 + Microsoft.Data.Sqlite 8.0.11 | behind IMachineRepository |
|
||||
| Logging | NLog 5.x (via Microsoft.Extensions.Logging) | Core = abstractions only |
|
||||
| Protocol #1 | MTConnect (HTTP + XML, System.Xml.Linq) | runtime plugin |
|
||||
| Tests | xUnit + Moq (net8.0) | 124+ tests |
|
||||
| Mock | Docker ladder99/agent | dev MTConnect agent :5000 |
|
||||
|
||||
## Links
|
||||
|
||||
| Resource | URL |
|
||||
|----------|-----|
|
||||
| Repository | ssh://3nt-git.duckdns.org:222/davide.trentin/junction.git |
|
||||
|
||||
---
|
||||
*PROJECT.md — Updated when requirements or context change*
|
||||
*Last updated: 2026-07-21*
|
||||
84
.paul/ROADMAP.md
Normal file
84
.paul/ROADMAP.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Roadmap: Junction
|
||||
|
||||
## Milestones
|
||||
|
||||
| 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 | - |
|
||||
|
||||
## 🚧 Active Milestone: v0.2 Machine Management UX
|
||||
|
||||
**Goal:** User fully manages a fleet of N machines from the UI (add/edit/delete + detail view), with reliable online/offline state and live monitor reload.
|
||||
**Status:** Phase 2 complete (commit pending)
|
||||
**Progress:** [██████████] 100%
|
||||
|
||||
### Phase 2: Machine Management UX
|
||||
|
||||
**Goal:** Detail screen, config CRUD, offline detection, dynamic monitor reload.
|
||||
**Depends on:** Phase 1
|
||||
**Research:** Unlikely (internal patterns)
|
||||
|
||||
**Plans:**
|
||||
- [x] 02-A: Core — offline detection (fail-threshold → Disconnected) + dynamic monitor API (AddOrUpdate/Remove machine live)
|
||||
- [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
|
||||
|
||||
**Goal:** Second protocol plugin (OPC UA) proving the multi-protocol architecture.
|
||||
**Prerequisite:** v0.2 complete
|
||||
**Estimated phases:** 1
|
||||
|
||||
| Phase | Focus | Research |
|
||||
|-------|-------|----------|
|
||||
| 3 | OPC UA driver plugin (endpoint/nodes → MachineSnapshot), config keys, tests | Likely (OPC UA SDK on net48) |
|
||||
|
||||
## 📋 Planned Milestone: v0.4 Fanuc FOCAS Protocol
|
||||
|
||||
**Goal:** Fanuc FOCAS plugin via native fwlib32 (P/Invoke, Windows-only).
|
||||
**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) |
|
||||
|
||||
## 📋 Planned Milestone: v0.5 History & Trends
|
||||
|
||||
**Goal:** Persist time-series, show trends in detail page.
|
||||
**Prerequisite:** v0.4 (or parallelizable)
|
||||
**Estimated phases:** 1
|
||||
|
||||
| Phase | Focus | Research |
|
||||
|-------|-------|----------|
|
||||
| 5 | History tables, retention, trend charts in detail view | Unlikely |
|
||||
|
||||
## ✅ Completed Milestones
|
||||
|
||||
<details>
|
||||
<summary>v0.1 Walking Skeleton (Phase 1) — Shipped 2026-07-21</summary>
|
||||
|
||||
### Phase 1: Walking Skeleton + MTConnect
|
||||
**Goal:** One configured machine polled by MTConnect plugin against docker mock, last datum live on Avalonia dashboard. net48 + net8.0 build on Linux.
|
||||
**Plans:** all complete (T1–T30 + unify), 115 tests + 2 docker integration, live e2e proven.
|
||||
|
||||
- [x] Solution scaffold, csproj set, Directory.Build.props, global.json
|
||||
- [x] Domain: Result<T>, models, IProtocolDriver/Factory, IMachineRepository, PluginManifest
|
||||
- [x] Core: PollingEngine, PluginLoader (Assembly.LoadFrom), MachineMonitor, DI
|
||||
- [x] Persistence: schema, SqliteMachineRepository (Dapper), swap seam
|
||||
- [x] MTConnect: HTTP driver + namespace-version-agnostic parser (1.7 + 2.0) + plugin manifest
|
||||
- [x] App: Avalonia shell (net48;net8.0), DI+NLog, live dashboard, seed machine, start monitor
|
||||
- [x] Docker MTConnect mock; canned XML fixtures; integration test (docker-gated)
|
||||
- [x] Committed + pushed to origin/main (507753f)
|
||||
|
||||
**Commit:** 507753f — `init: scaffold Junction multi-protocol machine monitor (M1 walking skeleton)`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-07-21*
|
||||
*Last updated: 2026-07-21*
|
||||
64
.paul/STATE.md
Normal file
64
.paul/STATE.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Project State
|
||||
|
||||
## Project Reference
|
||||
|
||||
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 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.
|
||||
|
||||
Progress:
|
||||
- Milestone v0.2: [██████████] 100%
|
||||
- Phase 2: 3 of 3 plans done
|
||||
|
||||
## Loop Position
|
||||
|
||||
```
|
||||
PLAN ──▶ APPLY ──▶ UNIFY
|
||||
✓ ✓ ◉ [Unifying — verified; awaiting commit]
|
||||
```
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
| Decision | Phase | Impact |
|
||||
|----------|-------|--------|
|
||||
| net48 mandatory (Win7/8 fleet) | 1 | Constrains all deps; Avalonia 11.3.x, Dapper not EF Core |
|
||||
| App multi-target net48;net8.0 (no DevHead) | 1 | Dev on Linux net8.0, ship net48 |
|
||||
| Dynamic plugins Assembly.LoadFrom | 1 | Add protocols without touching core |
|
||||
| Monitor dynamic API (AddOrUpdate/Remove) | 2 | Config changes take effect live, no restart |
|
||||
|
||||
### Deferred Issues
|
||||
|
||||
| Issue | Origin | Effort | Revisit |
|
||||
|-------|--------|--------|---------|
|
||||
| HttpClient created per driver in MTConnect factory | 1 | S | If drivers recreated frequently (socket exhaustion) |
|
||||
| Robust disconnection UX (state flip nuances) | 2 | S | After config CRUD lands |
|
||||
|
||||
### Blockers/Concerns
|
||||
|
||||
None.
|
||||
|
||||
## Boundaries (Active)
|
||||
|
||||
- M2-C touches: src/Junction.App/* (config VM/View, nav, dashboard/detail buttons, Bootstrapper) + src/Junction.Core (AvailableProtocols only) + tests.
|
||||
- Do NOT touch Domain/Persistence/Protocols in M2-C.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-07-21
|
||||
Stopped at: M2 complete — 3 screens (dashboard/detail/config), offline detection, dynamic monitor reload. Build 0/0, 125 tests + 2 docker-skip. NOT yet committed.
|
||||
Next action: commit + push v0.2 (code + .paul/) when user authorizes → then plan v0.3 OPC UA (/paul:plan). Note: OPC UA research Likely (SDK on net48).
|
||||
Resume context: Manager mode (dotnet-manager), all code delegated to subagents. Full detail in ~/.claude memory/junction-project.md. v0.1 committed 507753f on origin/main; v0.2 uncommitted.
|
||||
|
||||
---
|
||||
*STATE.md — Updated after every significant action*
|
||||
25
.paul/paul.json
Normal file
25
.paul/paul.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "Junction",
|
||||
"version": "0.1.0",
|
||||
"milestone": {
|
||||
"name": "Machine Management UX",
|
||||
"version": "0.2.0",
|
||||
"status": "complete_commit_pending"
|
||||
},
|
||||
"phase": {
|
||||
"number": 2,
|
||||
"name": "Machine Management UX",
|
||||
"status": "complete"
|
||||
},
|
||||
"loop": {
|
||||
"plan": "02-C",
|
||||
"position": "UNIFY"
|
||||
},
|
||||
"timestamps": {
|
||||
"created_at": "2026-07-21T23:30:00Z",
|
||||
"updated_at": "2026-07-21T23:45:00Z"
|
||||
},
|
||||
"satellite": {
|
||||
"groom": true
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,16 @@ namespace Junction.App
|
|||
services.AddSingleton<DashboardViewModel>();
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
|
||||
// Detail is per-navigation (transient); a Func factory lets the shell mint one per click.
|
||||
services.AddTransient<MachineDetailViewModel>();
|
||||
services.AddSingleton<Func<MachineDetailViewModel>>(sp =>
|
||||
() => sp.GetRequiredService<MachineDetailViewModel>());
|
||||
|
||||
// Config screen is per-navigation (transient); a Func factory mints one per add/edit.
|
||||
services.AddTransient<MachineConfigViewModel>();
|
||||
services.AddSingleton<Func<MachineConfigViewModel>>(sp =>
|
||||
() => sp.GetRequiredService<MachineConfigViewModel>());
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Junction.Core.Monitoring;
|
||||
using Junction.Domain.Models;
|
||||
using Junction.Domain.Persistence;
|
||||
|
|
@ -27,6 +28,12 @@ namespace Junction.App.ViewModels
|
|||
|
||||
public string Title => "Junction — Machines";
|
||||
|
||||
/// <summary>
|
||||
/// Shell reference, set by <see cref="MainWindowViewModel"/> after construction (breaks the
|
||||
/// otherwise-circular DI graph). Used to open a machine's detail screen on row click.
|
||||
/// </summary>
|
||||
public MainWindowViewModel? Navigator { get; set; }
|
||||
|
||||
public ObservableCollection<MachineRowViewModel> Machines { get; } = new ObservableCollection<MachineRowViewModel>();
|
||||
|
||||
public DashboardViewModel(IMachineRepository repository, IMachineMonitor monitor, ILogger<DashboardViewModel> logger)
|
||||
|
|
@ -61,7 +68,7 @@ namespace Junction.App.ViewModels
|
|||
var latest = _monitor.LatestSnapshots;
|
||||
foreach (var machine in result.Value)
|
||||
{
|
||||
var row = new MachineRowViewModel(machine);
|
||||
var row = new MachineRowViewModel(machine, OpenDetail);
|
||||
if (latest.TryGetValue(machine.Id, out var snapshot))
|
||||
{
|
||||
row.Apply(snapshot);
|
||||
|
|
@ -74,6 +81,21 @@ namespace Junction.App.ViewModels
|
|||
_logger.LogInformation("Dashboard loaded {Count} machine(s).", Machines.Count);
|
||||
}
|
||||
|
||||
/// <summary>Header action: opens the add-machine config screen via the shell.</summary>
|
||||
[RelayCommand]
|
||||
private void AddMachine() => Navigator?.ShowConfig(null);
|
||||
|
||||
/// <summary>Row-click handler: routes to the machine-detail screen via the shell.</summary>
|
||||
private void OpenDetail(MachineRowViewModel row)
|
||||
{
|
||||
if (row == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator?.ShowDetail(row.MachineId, row.Name);
|
||||
}
|
||||
|
||||
private void OnSnapshotUpdated(object? sender, MachineSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null)
|
||||
|
|
|
|||
27
src/Junction.App/ViewModels/DataItemRowViewModel.cs
Normal file
27
src/Junction.App/ViewModels/DataItemRowViewModel.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using Junction.Domain.Models;
|
||||
|
||||
namespace Junction.App.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// One row of the machine-detail data-item table. Immutable projection of a
|
||||
/// <see cref="DataItem"/>; rebuilt (not mutated) whenever a fresh snapshot arrives,
|
||||
/// so it needs no change-notification.
|
||||
/// </summary>
|
||||
public sealed class DataItemRowViewModel
|
||||
{
|
||||
public string Id { get; }
|
||||
public string Name { get; }
|
||||
public string Value { get; }
|
||||
public string Category { get; }
|
||||
public string Timestamp { get; }
|
||||
|
||||
public DataItemRowViewModel(DataItem item)
|
||||
{
|
||||
Id = item.Id;
|
||||
Name = string.IsNullOrWhiteSpace(item.Name) ? item.Id : item.Name;
|
||||
Value = string.IsNullOrWhiteSpace(item.Value) ? "—" : item.Value;
|
||||
Category = string.IsNullOrWhiteSpace(item.Category) ? "—" : item.Category;
|
||||
Timestamp = item.Timestamp.LocalDateTime.ToString("HH:mm:ss");
|
||||
}
|
||||
}
|
||||
}
|
||||
208
src/Junction.App/ViewModels/MachineConfigViewModel.cs
Normal file
208
src/Junction.App/ViewModels/MachineConfigViewModel.cs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Junction.Core.Monitoring;
|
||||
using Junction.Domain.Models;
|
||||
using Junction.Domain.Persistence;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Junction.App.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// Add/edit machine form. Two modes selected by <see cref="Initialize"/>:
|
||||
/// null id = ADD (blank form, fresh <see cref="Guid"/> on save); non-null = EDIT
|
||||
/// (prefill from the repository, keep the existing id). Saving persists via the repository
|
||||
/// and then applies the change live through <see cref="IMachineMonitor.AddOrUpdateMachineAsync"/>
|
||||
/// so the poll loop starts/restarts without an app restart. <see cref="Navigator"/> routes back
|
||||
/// to the dashboard (and triggers its reload) once the mutation completes.
|
||||
/// <para>Created per-navigation (transient). Set <see cref="Navigator"/>, then call
|
||||
/// <see cref="Initialize"/>.</para>
|
||||
/// </summary>
|
||||
public sealed partial class MachineConfigViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IMachineRepository _repository;
|
||||
private readonly IMachineMonitor _monitor;
|
||||
private readonly ILogger<MachineConfigViewModel> _logger;
|
||||
|
||||
private Guid? _editingId;
|
||||
|
||||
/// <summary>Shell reference used to return to the dashboard after save/cancel.</summary>
|
||||
public MainWindowViewModel? Navigator { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
[NotifyPropertyChangedFor(nameof(ValidationError))]
|
||||
[NotifyPropertyChangedFor(nameof(HasValidationError))]
|
||||
private string _name = "";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
[NotifyPropertyChangedFor(nameof(ValidationError))]
|
||||
[NotifyPropertyChangedFor(nameof(HasValidationError))]
|
||||
private string _selectedProtocol = "";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
[NotifyPropertyChangedFor(nameof(ValidationError))]
|
||||
[NotifyPropertyChangedFor(nameof(HasValidationError))]
|
||||
private string _agentUrl = "";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
[NotifyPropertyChangedFor(nameof(ValidationError))]
|
||||
[NotifyPropertyChangedFor(nameof(HasValidationError))]
|
||||
private int _pollIntervalSeconds = 2;
|
||||
|
||||
[ObservableProperty] private bool _isEdit;
|
||||
|
||||
/// <summary>Protocol ids offered in the dropdown, sourced from the monitor at Initialize.</summary>
|
||||
public ObservableCollection<string> AvailableProtocols { get; } = new ObservableCollection<string>();
|
||||
|
||||
public MachineConfigViewModel(
|
||||
IMachineRepository repository,
|
||||
IMachineMonitor monitor,
|
||||
ILogger<MachineConfigViewModel> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_monitor = monitor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Title => IsEdit ? "Edit Machine" : "Add Machine";
|
||||
|
||||
/// <summary>Validation message for display; empty when the form is valid.</summary>
|
||||
public string ValidationError => Validate() ?? "";
|
||||
|
||||
public bool HasValidationError => Validate() != null;
|
||||
|
||||
partial void OnIsEditChanged(bool value) => OnPropertyChanged(nameof(Title));
|
||||
|
||||
/// <summary>
|
||||
/// Enters ADD (null) or EDIT (existing id) mode. Populates the protocol dropdown from the
|
||||
/// monitor synchronously; for EDIT, prefills the form from the repository (fire-and-forget,
|
||||
/// surfaces its own failures via the logger).
|
||||
/// </summary>
|
||||
public void Initialize(Guid? machineId)
|
||||
{
|
||||
PopulateProtocols();
|
||||
|
||||
_editingId = machineId;
|
||||
IsEdit = machineId.HasValue;
|
||||
|
||||
if (machineId is null)
|
||||
{
|
||||
Name = "";
|
||||
SelectedProtocol = AvailableProtocols.Count > 0 ? AvailableProtocols[0] : "";
|
||||
AgentUrl = "";
|
||||
PollIntervalSeconds = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = LoadExistingAsync(machineId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateProtocols()
|
||||
{
|
||||
AvailableProtocols.Clear();
|
||||
foreach (var protocol in _monitor.AvailableProtocols)
|
||||
{
|
||||
AvailableProtocols.Add(protocol);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadExistingAsync(Guid machineId)
|
||||
{
|
||||
var result = await _repository.GetByIdAsync(machineId, CancellationToken.None).ConfigureAwait(true);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
var detail = result.Errors.Count > 0 ? result.Errors[0].Message : "unknown error";
|
||||
_logger.LogError("Config load (GetById) failed for {MachineId}: {Detail}", machineId, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
var m = result.Value;
|
||||
Name = m.Name;
|
||||
|
||||
// Ensure the machine's protocol is selectable even if its plugin is not currently loaded.
|
||||
if (!string.IsNullOrWhiteSpace(m.ProtocolId) && !AvailableProtocols.Contains(m.ProtocolId))
|
||||
{
|
||||
AvailableProtocols.Add(m.ProtocolId);
|
||||
}
|
||||
|
||||
SelectedProtocol = m.ProtocolId;
|
||||
AgentUrl = m.ConnectionConfig.TryGetValue("AgentUrl", out var url) ? url : "";
|
||||
PollIntervalSeconds = m.PollInterval.TotalSeconds > 0 ? (int)m.PollInterval.TotalSeconds : 1;
|
||||
}
|
||||
|
||||
private string? Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
return "Name is required.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SelectedProtocol))
|
||||
{
|
||||
return "Protocol is required.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(AgentUrl) || !Uri.TryCreate(AgentUrl, UriKind.Absolute, out _))
|
||||
{
|
||||
return "Agent URL must be a valid absolute URI (e.g. http://host:5000).";
|
||||
}
|
||||
|
||||
if (PollIntervalSeconds <= 0)
|
||||
{
|
||||
return "Poll interval must be greater than 0 seconds.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool CanSave() => Validate() is null;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private async Task Save()
|
||||
{
|
||||
var id = _editingId ?? Guid.NewGuid();
|
||||
var machine = new Machine(
|
||||
id,
|
||||
Name.Trim(),
|
||||
SelectedProtocol,
|
||||
new Dictionary<string, string> { ["AgentUrl"] = AgentUrl.Trim() },
|
||||
TimeSpan.FromSeconds(PollIntervalSeconds));
|
||||
|
||||
var upsert = await _repository.UpsertAsync(machine, CancellationToken.None).ConfigureAwait(true);
|
||||
if (!upsert.IsSuccess)
|
||||
{
|
||||
var detail = upsert.Errors.Count > 0 ? upsert.Errors[0].Message : "unknown error";
|
||||
_logger.LogError("Machine upsert failed for {MachineId}: {Detail}", id, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply live: start (add) or restart (edit) the poll loop. A failure here is not fatal —
|
||||
// the config is persisted and will be picked up on next start; log and continue.
|
||||
var live = await _monitor.AddOrUpdateMachineAsync(machine, CancellationToken.None).ConfigureAwait(true);
|
||||
if (!live.IsSuccess)
|
||||
{
|
||||
var detail = live.Errors.Count > 0 ? live.Errors[0].Message : "unknown error";
|
||||
_logger.LogWarning("Live apply (AddOrUpdate) failed for {MachineId}; config saved: {Detail}", id, detail);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Machine {MachineId} ({Name}) saved via config screen.", id, machine.Name);
|
||||
|
||||
if (Navigator != null)
|
||||
{
|
||||
await Navigator.GoToDashboardAndReloadAsync().ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel() => Navigator?.GoToDashboard();
|
||||
}
|
||||
}
|
||||
228
src/Junction.App/ViewModels/MachineDetailViewModel.cs
Normal file
228
src/Junction.App/ViewModels/MachineDetailViewModel.cs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Junction.Core.Monitoring;
|
||||
using Junction.Domain.Models;
|
||||
using Junction.Domain.Persistence;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Junction.App.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// Full current read of a single machine: header (machine config) + the complete latest
|
||||
/// snapshot (all data items + connection state + capture time). Seeds from the monitor's
|
||||
/// latest-snapshot cache (falling back to the repository), then refreshes live as
|
||||
/// <see cref="IMachineMonitor.SnapshotUpdated"/> fires for this machine. Snapshot events
|
||||
/// arrive on poll-loop threads and are marshalled onto the UI thread.
|
||||
/// <para>Created per-navigation (transient); call <see cref="Initialize"/> then
|
||||
/// <see cref="LoadAsync"/>. <see cref="Navigator"/> is set by the shell to route Back.</para>
|
||||
/// </summary>
|
||||
public sealed partial class MachineDetailViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IMachineRepository _repository;
|
||||
private readonly IMachineMonitor _monitor;
|
||||
private readonly ILogger<MachineDetailViewModel> _logger;
|
||||
private Guid _machineId;
|
||||
private bool _subscribed;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Shell reference used by <see cref="BackCommand"/> to return to the dashboard.</summary>
|
||||
public MainWindowViewModel? Navigator { get; set; }
|
||||
|
||||
[ObservableProperty] 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] private string _capturedAt = "—";
|
||||
[ObservableProperty] private int _itemCount;
|
||||
|
||||
/// <summary>Two-state delete guard: first click arms, second click confirms.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DeleteButtonText))]
|
||||
private bool _confirmingDelete;
|
||||
|
||||
public string DeleteButtonText => ConfirmingDelete ? "Confirm delete?" : "Delete";
|
||||
|
||||
/// <summary>The full set of current data items for this machine.</summary>
|
||||
public ObservableCollection<DataItemRowViewModel> Items { get; } =
|
||||
new ObservableCollection<DataItemRowViewModel>();
|
||||
|
||||
public MachineDetailViewModel(
|
||||
IMachineRepository repository,
|
||||
IMachineMonitor monitor,
|
||||
ILogger<MachineDetailViewModel> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_monitor = monitor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Sets the target machine. Call before <see cref="LoadAsync"/>.</summary>
|
||||
public void Initialize(Guid machineId, string machineName)
|
||||
{
|
||||
_machineId = machineId;
|
||||
MachineName = string.IsNullOrWhiteSpace(machineName) ? "—" : machineName;
|
||||
MachineIdText = machineId.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Loads the machine header + latest snapshot and subscribes to live updates.</summary>
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
// Subscribe first so no update slips through between load and subscribe;
|
||||
// events for other machines are ignored by id.
|
||||
if (!_subscribed)
|
||||
{
|
||||
_monitor.SnapshotUpdated += OnSnapshotUpdated;
|
||||
_subscribed = true;
|
||||
}
|
||||
|
||||
var machineResult = await _repository.GetByIdAsync(_machineId, CancellationToken.None).ConfigureAwait(true);
|
||||
if (machineResult.IsSuccess)
|
||||
{
|
||||
var m = machineResult.Value;
|
||||
MachineName = string.IsNullOrWhiteSpace(m.Name) ? "—" : m.Name;
|
||||
ProtocolId = string.IsNullOrWhiteSpace(m.ProtocolId) ? "—" : m.ProtocolId;
|
||||
PollInterval = m.PollInterval.ToString();
|
||||
AgentUrl = m.ConnectionConfig.TryGetValue("AgentUrl", out var url) && !string.IsNullOrWhiteSpace(url)
|
||||
? url
|
||||
: "—";
|
||||
}
|
||||
else
|
||||
{
|
||||
var detail = machineResult.Errors.Count > 0 ? machineResult.Errors[0].Message : "unknown error";
|
||||
_logger.LogError("Machine detail load (GetById) failed for {MachineId}: {Detail}", _machineId, detail);
|
||||
}
|
||||
|
||||
// Prefer the monitor's live cache; fall back to the persisted latest snapshot.
|
||||
MachineSnapshot? snapshot = null;
|
||||
if (_monitor.LatestSnapshots.TryGetValue(_machineId, out var cached))
|
||||
{
|
||||
snapshot = cached;
|
||||
}
|
||||
else
|
||||
{
|
||||
var snapResult = await _repository.GetLatestSnapshotAsync(_machineId, CancellationToken.None).ConfigureAwait(true);
|
||||
if (snapResult.IsSuccess)
|
||||
{
|
||||
snapshot = snapResult.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot != null)
|
||||
{
|
||||
ApplySnapshot(snapshot);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Machine detail opened for {Name} ({MachineId}); {Count} item(s).",
|
||||
MachineName, _machineId, ItemCount);
|
||||
}
|
||||
|
||||
/// <summary>Projects a snapshot onto the header state + item table. Call on the UI thread.</summary>
|
||||
private void ApplySnapshot(MachineSnapshot snapshot)
|
||||
{
|
||||
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++)
|
||||
{
|
||||
Items.Add(new DataItemRowViewModel(snapshot.Items[i]));
|
||||
}
|
||||
|
||||
ItemCount = Items.Count;
|
||||
}
|
||||
|
||||
private void OnSnapshotUpdated(object? sender, MachineSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null || snapshot.MachineId != _machineId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Event fires on a poll-loop thread → marshal all observable mutations to the UI thread.
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Back()
|
||||
{
|
||||
Dispose();
|
||||
Navigator?.GoToDashboard();
|
||||
}
|
||||
|
||||
/// <summary>Opens the edit config screen for this machine.</summary>
|
||||
[RelayCommand]
|
||||
private void Edit()
|
||||
{
|
||||
Dispose();
|
||||
Navigator?.ShowConfig(_machineId);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task Delete()
|
||||
{
|
||||
if (!ConfirmingDelete)
|
||||
{
|
||||
ConfirmingDelete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var removed = await _monitor.RemoveMachineAsync(_machineId, CancellationToken.None).ConfigureAwait(true);
|
||||
if (!removed.IsSuccess)
|
||||
{
|
||||
var detail = removed.Errors.Count > 0 ? removed.Errors[0].Message : "unknown error";
|
||||
_logger.LogWarning("Live remove failed for {MachineId}; machine deleted from store: {Detail}", _machineId, detail);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Machine {MachineId} deleted via detail screen.", _machineId);
|
||||
|
||||
Dispose();
|
||||
if (Navigator != null)
|
||||
{
|
||||
await Navigator.GoToDashboardAndReloadAsync().ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
if (_subscribed)
|
||||
{
|
||||
_monitor.SnapshotUpdated -= OnSnapshotUpdated;
|
||||
_subscribed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Junction.Domain.Models;
|
||||
|
||||
namespace Junction.App.ViewModels
|
||||
|
|
@ -10,6 +11,8 @@ namespace Junction.App.ViewModels
|
|||
/// </summary>
|
||||
public sealed partial class MachineRowViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Action<MachineRowViewModel>? _onOpenDetail;
|
||||
|
||||
public Guid MachineId { get; }
|
||||
|
||||
[ObservableProperty] private string _name;
|
||||
|
|
@ -18,8 +21,9 @@ namespace Junction.App.ViewModels
|
|||
[ObservableProperty] private string _lastDatum;
|
||||
[ObservableProperty] private string _lastUpdated;
|
||||
|
||||
public MachineRowViewModel(Machine machine)
|
||||
public MachineRowViewModel(Machine machine, Action<MachineRowViewModel>? onOpenDetail = null)
|
||||
{
|
||||
_onOpenDetail = onOpenDetail;
|
||||
MachineId = machine.Id;
|
||||
_name = machine.Name;
|
||||
_protocolId = machine.ProtocolId;
|
||||
|
|
@ -28,6 +32,10 @@ namespace Junction.App.ViewModels
|
|||
_lastUpdated = "—";
|
||||
}
|
||||
|
||||
/// <summary>Opens the detail screen for this machine via the dashboard-supplied callback.</summary>
|
||||
[RelayCommand]
|
||||
private void OpenDetail() => _onOpenDetail?.Invoke(this);
|
||||
|
||||
/// <summary>Projects a snapshot onto this row. Call on the UI thread.</summary>
|
||||
public void Apply(MachineSnapshot snapshot)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,22 +1,74 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Junction.App.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// Shell view-model. Hosts the currently shown page. Only the dashboard is used this slice;
|
||||
/// <see cref="Navigate"/> is the seam for detail (T21) / config (T22) navigation.
|
||||
/// Shell view-model. Hosts the currently shown page and owns navigation between the
|
||||
/// dashboard (T20), per-machine detail (T21), and the add/edit config screen (M2-C).
|
||||
/// <see cref="Navigate"/> is the low-level seam; <see cref="ShowDetail"/> /
|
||||
/// <see cref="ShowConfig"/> / <see cref="GoToDashboard"/> are the app-level transitions.
|
||||
/// </summary>
|
||||
public sealed partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
private readonly DashboardViewModel _dashboard;
|
||||
private readonly Func<MachineDetailViewModel> _detailFactory;
|
||||
private readonly Func<MachineConfigViewModel> _configFactory;
|
||||
|
||||
[ObservableProperty]
|
||||
private object? _currentPage;
|
||||
|
||||
public MainWindowViewModel(DashboardViewModel dashboard)
|
||||
public MainWindowViewModel(
|
||||
DashboardViewModel dashboard,
|
||||
Func<MachineDetailViewModel> detailFactory,
|
||||
Func<MachineConfigViewModel> configFactory)
|
||||
{
|
||||
_dashboard = dashboard;
|
||||
_detailFactory = detailFactory;
|
||||
_configFactory = configFactory;
|
||||
|
||||
// Give the dashboard a way back to the shell (post-construction wiring avoids a DI cycle).
|
||||
_dashboard.Navigator = this;
|
||||
_currentPage = dashboard;
|
||||
}
|
||||
|
||||
/// <summary>Navigation seam. Swaps the hosted page. (Only Dashboard wired now.)</summary>
|
||||
/// <summary>Navigation seam. Swaps the hosted page.</summary>
|
||||
public void Navigate(object viewModel) => CurrentPage = viewModel;
|
||||
|
||||
/// <summary>Returns to the (singleton, still-live) dashboard.</summary>
|
||||
public void GoToDashboard() => CurrentPage = _dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Returns to the dashboard and rebuilds its rows from the repository so an add/edit/delete
|
||||
/// is reflected immediately (new row appears, deleted row gone). Preserves the live snapshot
|
||||
/// subscription. Call on the UI thread.
|
||||
/// </summary>
|
||||
public async Task GoToDashboardAndReloadAsync()
|
||||
{
|
||||
CurrentPage = _dashboard;
|
||||
await _dashboard.LoadAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Opens the add (null id) or edit (existing id) config screen.</summary>
|
||||
public void ShowConfig(Guid? machineId)
|
||||
{
|
||||
var config = _configFactory();
|
||||
config.Navigator = this;
|
||||
config.Initialize(machineId);
|
||||
Navigate(config);
|
||||
}
|
||||
|
||||
/// <summary>Builds a fresh detail page for the given machine, loads it, and shows it.</summary>
|
||||
public void ShowDetail(Guid machineId, string machineName)
|
||||
{
|
||||
var detail = _detailFactory();
|
||||
detail.Navigator = this;
|
||||
detail.Initialize(machineId, machineName);
|
||||
Navigate(detail);
|
||||
|
||||
// Fire-and-forget: LoadAsync surfaces its own failures via the logger and never throws.
|
||||
_ = detail.LoadAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,20 +9,27 @@
|
|||
x:CompileBindings="True">
|
||||
|
||||
<DockPanel Margin="16">
|
||||
<TextBlock DockPanel.Dock="Top"
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto" Margin="0,0,0,12">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Title}"
|
||||
FontSize="22" FontWeight="SemiBold"
|
||||
Margin="0,0,0,12" />
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1"
|
||||
Content="+ Add machine"
|
||||
Command="{Binding AddMachineCommand}"
|
||||
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*">
|
||||
<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>
|
||||
|
||||
|
|
@ -30,7 +37,7 @@
|
|||
<ItemsControl ItemsSource="{Binding Machines}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MachineRowViewModel">
|
||||
<Grid ColumnDefinitions="2*,1.2*,3*,1.2*" Margin="0,6">
|
||||
<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" />
|
||||
|
|
@ -38,6 +45,9 @@
|
|||
<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>
|
||||
|
|
|
|||
61
src/Junction.App/Views/MachineConfigView.axaml
Normal file
61
src/Junction.App/Views/MachineConfigView.axaml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
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"
|
||||
mc:Ignorable="d"
|
||||
x:Class="Junction.App.Views.MachineConfigView"
|
||||
x:DataType="vm:MachineConfigViewModel"
|
||||
x:CompileBindings="True">
|
||||
|
||||
<DockPanel Margin="16">
|
||||
|
||||
<!-- Header -->
|
||||
<TextBlock DockPanel.Dock="Top"
|
||||
Text="{Binding Title}"
|
||||
FontSize="22" FontWeight="SemiBold"
|
||||
Margin="0,0,0,16" />
|
||||
|
||||
<!-- Action bar -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="12" Margin="0,16,0,0">
|
||||
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" />
|
||||
<Button Content="Cancel" Command="{Binding CancelCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Inline validation hint -->
|
||||
<TextBlock DockPanel.Dock="Bottom"
|
||||
Text="{Binding ValidationError}"
|
||||
IsVisible="{Binding HasValidationError}"
|
||||
Foreground="#C0392B"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,12,0,0" />
|
||||
|
||||
<!-- Form -->
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="12" MaxWidth="480" HorizontalAlignment="Left">
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Name" FontWeight="SemiBold" />
|
||||
<TextBox Text="{Binding Name}" Watermark="Machine name" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Protocol" FontWeight="SemiBold" />
|
||||
<ComboBox ItemsSource="{Binding AvailableProtocols}"
|
||||
SelectedItem="{Binding SelectedProtocol}"
|
||||
HorizontalAlignment="Stretch"
|
||||
PlaceholderText="Select a protocol" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Agent URL" FontWeight="SemiBold" />
|
||||
<TextBox Text="{Binding AgentUrl}" Watermark="http://host:5000" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Poll Interval (seconds)" FontWeight="SemiBold" />
|
||||
<TextBox Text="{Binding PollIntervalSeconds}" Width="120" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
12
src/Junction.App/Views/MachineConfigView.axaml.cs
Normal file
12
src/Junction.App/Views/MachineConfigView.axaml.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Junction.App.Views
|
||||
{
|
||||
public partial class MachineConfigView : UserControl
|
||||
{
|
||||
public MachineConfigView() => InitializeComponent();
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
86
src/Junction.App/Views/MachineDetailView.axaml
Normal file
86
src/Junction.App/Views/MachineDetailView.axaml
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
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"
|
||||
mc:Ignorable="d"
|
||||
x:Class="Junction.App.Views.MachineDetailView"
|
||||
x:DataType="vm:MachineDetailViewModel"
|
||||
x:CompileBindings="True">
|
||||
|
||||
<DockPanel Margin="16">
|
||||
|
||||
<!-- Header bar: Back + machine name + Edit/Delete -->
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="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}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="3" Content="{Binding DeleteButtonText}" Command="{Binding DeleteCommand}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<!-- Machine info panel -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
CornerRadius="4"
|
||||
Padding="12" Margin="0,0,0,12">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="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="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="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" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Data-items header -->
|
||||
<TextBlock DockPanel.Dock="Top"
|
||||
Text="{Binding ItemCount, StringFormat='Data Items ({0})'}"
|
||||
FontSize="16" FontWeight="SemiBold" Margin="0,0,0,6" />
|
||||
|
||||
<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*,2*,1.2*,1.2*">
|
||||
<TextBlock Grid.Column="0" Text="Name" FontWeight="Bold" />
|
||||
<TextBlock Grid.Column="1" Text="Value" FontWeight="Bold" />
|
||||
<TextBlock Grid.Column="2" Text="Category" FontWeight="Bold" />
|
||||
<TextBlock Grid.Column="3" Text="Timestamp" FontWeight="Bold" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Full data-item table -->
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Items}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DataItemRowViewModel">
|
||||
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*" Margin="0,5">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Id}" FontSize="11" Opacity="0.6" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Value}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Category}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Text="{Binding Timestamp}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
12
src/Junction.App/Views/MachineDetailView.axaml.cs
Normal file
12
src/Junction.App/Views/MachineDetailView.axaml.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Junction.App.Views
|
||||
{
|
||||
public partial class MachineDetailView : UserControl
|
||||
{
|
||||
public MachineDetailView() => InitializeComponent();
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"protocolId": "mtconnect",
|
||||
"displayName": "MTConnect",
|
||||
"assemblyFile": "Junction.Protocols.MTConnect.dll",
|
||||
"entryTypeName": "Junction.Protocols.MTConnect.MtconnectDriverFactory",
|
||||
"apiVersion": "1.0"
|
||||
}
|
||||
|
|
@ -35,6 +35,14 @@ namespace Junction.Core.Monitoring
|
|||
/// </summary>
|
||||
IReadOnlyDictionary<Guid, MachineSnapshot> LatestSnapshots { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Protocol ids of every plugin loaded by <see cref="StartAsync"/> (the keys of the retained
|
||||
/// factory map). Empty before <see cref="StartAsync"/> completes. Thread-safe: each read
|
||||
/// returns an independent snapshot of the current keys. The config screen binds this to its
|
||||
/// protocol dropdown.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<string> AvailableProtocols { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Loads plugins from <paramref name="pluginsDirectory"/>, loads configured machines,
|
||||
/// and starts a poll loop for each machine with a matching protocol driver.
|
||||
|
|
@ -52,5 +60,29 @@ namespace Junction.Core.Monitoring
|
|||
/// not started. After it returns, no further snapshots are produced.
|
||||
/// </summary>
|
||||
Task StopAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new machine to the running monitor, or restarts an already-running machine with
|
||||
/// updated configuration (config screen use case). Idempotent restart: if a poll loop for
|
||||
/// <paramref name="machine"/>'s id already exists it is stopped and awaited, dropped from
|
||||
/// tracking and from <see cref="LatestSnapshots"/>, then a fresh loop is started — so a
|
||||
/// machine never ends up with two concurrent loops.
|
||||
/// <para>
|
||||
/// Requires <see cref="StartAsync"/> to have completed (the protocol factory map is built
|
||||
/// there): returns <see cref="Result.Fail"/> ("monitor not started") otherwise. Returns
|
||||
/// <see cref="Result.Fail"/> when the machine's protocol has no loaded plugin or its driver
|
||||
/// cannot be created, and <see cref="Result.Cancelled"/> if the token trips first. On
|
||||
/// success the loop is running and <see cref="Result.Ok"/> is returned.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
Task<Result> AddOrUpdateMachineAsync(Machine machine, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Stops and awaits the poll loop for <paramref name="machineId"/> (if any) and drops it
|
||||
/// from tracking and from <see cref="LatestSnapshots"/>. Idempotent: returns
|
||||
/// <see cref="Result.Ok"/> even when no loop was running for the id. Does not require the
|
||||
/// monitor to have been started.
|
||||
/// </summary>
|
||||
Task<Result> RemoveMachineAsync(Guid machineId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ namespace Junction.Core.Monitoring
|
|||
private readonly object _lifecycleLock = new object();
|
||||
private readonly List<RunningLoop> _running = new List<RunningLoop>();
|
||||
|
||||
// Built once by StartAsync and kept so dynamic add/update can resolve drivers at runtime.
|
||||
// Guarded by _lifecycleLock. Null until StartAsync completes = "monitor not started".
|
||||
private Dictionary<string, IProtocolDriverFactory>? _factories;
|
||||
|
||||
public MachineMonitor(
|
||||
IMachineRepository repository,
|
||||
IPluginLoader pluginLoader,
|
||||
|
|
@ -63,6 +67,24 @@ namespace Junction.Core.Monitoring
|
|||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<Guid, MachineSnapshot> LatestSnapshots => _snapshots;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<string> AvailableProtocols
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
if (_factories is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
// Independent snapshot of the keys so callers can't observe later mutations.
|
||||
return new List<string>(_factories.Keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> StartAsync(string pluginsDirectory, CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
@ -109,6 +131,9 @@ namespace Junction.Core.Monitoring
|
|||
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
// Retain the factory map so AddOrUpdateMachineAsync can build drivers later.
|
||||
_factories = factories;
|
||||
|
||||
foreach (Machine machine in machinesResult.Value)
|
||||
{
|
||||
if (!factories.TryGetValue(machine.ProtocolId, out IProtocolDriverFactory factory))
|
||||
|
|
@ -205,6 +230,164 @@ namespace Junction.Core.Monitoring
|
|||
_logger.LogInformation("MachineMonitor stopped: {Count} poll loop(s) shut down.", loops.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> AddOrUpdateMachineAsync(Machine machine, CancellationToken cancellationToken)
|
||||
{
|
||||
if (machine is null)
|
||||
{
|
||||
return Result.Fail(OperationError.Of(Source, "machine is null."));
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Result.Cancelled();
|
||||
}
|
||||
|
||||
// Phase 1: detach any existing loop for this id (under lock), enforcing "not started".
|
||||
RunningLoop? existing;
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
if (_factories is null)
|
||||
{
|
||||
return Result.Fail(OperationError.Of(Source, "Monitor not started; call StartAsync first."));
|
||||
}
|
||||
|
||||
existing = FindAndRemoveLocked(machine.Id);
|
||||
}
|
||||
|
||||
// Phase 2: await the old loop's clean shutdown OUTSIDE the lock (no await under lock).
|
||||
if (existing != null)
|
||||
{
|
||||
await StopLoopAsync(existing).ConfigureAwait(false);
|
||||
_snapshots.TryRemove(machine.Id, out _);
|
||||
}
|
||||
|
||||
// Phase 3: start a fresh loop (under lock).
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
if (_factories is null)
|
||||
{
|
||||
return Result.Fail(OperationError.Of(Source, "Monitor not started; call StartAsync first."));
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
Result started = TryStartLoopLocked(machine, cts);
|
||||
if (!started.IsSuccess)
|
||||
{
|
||||
cts.Dispose();
|
||||
_logger.LogWarning(
|
||||
"AddOrUpdate failed to start machine {MachineId} ({MachineName}): {Errors}",
|
||||
machine.Id, machine.Name, DescribeErrors(started.Errors));
|
||||
return started;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Machine {MachineId} ({MachineName}) started/restarted via AddOrUpdate.",
|
||||
machine.Id, machine.Name);
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> RemoveMachineAsync(Guid machineId, CancellationToken cancellationToken)
|
||||
{
|
||||
RunningLoop? existing;
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
existing = FindAndRemoveLocked(machineId);
|
||||
}
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
await StopLoopAsync(existing).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_snapshots.TryRemove(machineId, out _);
|
||||
_logger.LogInformation("Machine {MachineId} removed from monitor (was running: {Running}).", machineId, existing != null);
|
||||
|
||||
// Idempotent: Ok even when nothing was running for the id.
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes and returns the tracked loop for <paramref name="machineId"/>, or null.
|
||||
/// Caller must hold <see cref="_lifecycleLock"/>.
|
||||
/// </summary>
|
||||
private RunningLoop? FindAndRemoveLocked(Guid machineId)
|
||||
{
|
||||
for (int i = 0; i < _running.Count; i++)
|
||||
{
|
||||
if (_running[i].MachineId == machineId)
|
||||
{
|
||||
RunningLoop loop = _running[i];
|
||||
_running.RemoveAt(i);
|
||||
return loop;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Cancels, awaits and disposes a single loop. Never throws.</summary>
|
||||
private async Task StopLoopAsync(RunningLoop loop)
|
||||
{
|
||||
try
|
||||
{
|
||||
loop.Cts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed; ignore.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await loop.Loop.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Poll loop faulted while stopping machine {MachineId}; continuing.", loop.MachineId);
|
||||
}
|
||||
|
||||
loop.Cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a driver for <paramref name="machine"/> via the retained factory map and starts
|
||||
/// a poll loop tracked in <see cref="_running"/>. Caller must hold <see cref="_lifecycleLock"/>
|
||||
/// and have verified <see cref="_factories"/> is non-null. Returns Fail (unknown protocol /
|
||||
/// driver-create failure) without adding a loop.
|
||||
/// </summary>
|
||||
private Result TryStartLoopLocked(Machine machine, CancellationTokenSource cts)
|
||||
{
|
||||
Dictionary<string, IProtocolDriverFactory> factories = _factories!;
|
||||
|
||||
if (!factories.TryGetValue(machine.ProtocolId, out IProtocolDriverFactory factory))
|
||||
{
|
||||
return Result.Fail(OperationError.Of(
|
||||
Source,
|
||||
$"No plugin loaded for protocol '{machine.ProtocolId}'."));
|
||||
}
|
||||
|
||||
Result<IProtocolDriver> driverResult = factory.Create(machine);
|
||||
if (!driverResult.IsSuccess)
|
||||
{
|
||||
return Result.Fail(driverResult.Errors);
|
||||
}
|
||||
|
||||
IProtocolDriver driver = driverResult.Value;
|
||||
IPollingEngine engine = _engineFactory();
|
||||
CancellationToken loopToken = cts.Token;
|
||||
Task loop = engine.RunAsync(
|
||||
machine,
|
||||
driver,
|
||||
snapshot => OnSnapshot(snapshot, loopToken),
|
||||
loopToken);
|
||||
|
||||
_running.Add(new RunningLoop(machine.Id, cts, loop));
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot callback invoked from a poll loop: updates the latest cache, raises the
|
||||
/// event, then fires the persistence write off (fire-and-forget with error logging) so
|
||||
|
|
|
|||
|
|
@ -34,10 +34,18 @@ namespace Junction.Core.Polling
|
|||
/// failed or cancelled reads.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Stops the loop when cancelled.</param>
|
||||
/// <param name="offlineThreshold">
|
||||
/// Number of consecutive failed reads after which a single synthetic
|
||||
/// <see cref="ConnectionState.Disconnected"/> snapshot is emitted through
|
||||
/// <paramref name="onSnapshot"/> (so the dashboard flips offline). Emitted at most once
|
||||
/// per offline episode; the next successful read resets it. A value <= 0 disables it.
|
||||
/// Defaults to 3, keeping existing callers unaffected.
|
||||
/// </param>
|
||||
Task RunAsync(
|
||||
Machine machine,
|
||||
IProtocolDriver driver,
|
||||
Action<MachineSnapshot> onSnapshot,
|
||||
CancellationToken cancellationToken);
|
||||
CancellationToken cancellationToken,
|
||||
int offlineThreshold = 3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,12 +31,16 @@ namespace Junction.Core.Polling
|
|||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>Default consecutive-failure count that flips a machine to Disconnected.</summary>
|
||||
public const int DefaultOfflineThreshold = 3;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAsync(
|
||||
Machine machine,
|
||||
IProtocolDriver driver,
|
||||
Action<MachineSnapshot> onSnapshot,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
int offlineThreshold = DefaultOfflineThreshold)
|
||||
{
|
||||
if (machine is null) throw new ArgumentNullException(nameof(machine));
|
||||
if (driver is null) throw new ArgumentNullException(nameof(driver));
|
||||
|
|
@ -44,8 +48,16 @@ namespace Junction.Core.Polling
|
|||
|
||||
TimeSpan interval = machine.PollInterval;
|
||||
|
||||
// Offline detection: count consecutive failed reads. When the count reaches
|
||||
// offlineThreshold we emit ONE synthetic Disconnected snapshot so the dashboard
|
||||
// flips offline, then stay quiet (offlineEmitted guard) until a successful read
|
||||
// ends the episode. A threshold <= 0 disables the feature.
|
||||
int failCount = 0;
|
||||
bool offlineEmitted = false;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
bool readFailed = false;
|
||||
Result<MachineSnapshot> result;
|
||||
try
|
||||
{
|
||||
|
|
@ -65,9 +77,10 @@ namespace Junction.Core.Polling
|
|||
machine.Id,
|
||||
machine.Name);
|
||||
result = null!;
|
||||
readFailed = true;
|
||||
}
|
||||
|
||||
if (result is object)
|
||||
if (!readFailed && result is object)
|
||||
{
|
||||
if (result.WasCancelled)
|
||||
{
|
||||
|
|
@ -77,6 +90,10 @@ namespace Junction.Core.Polling
|
|||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
// Recovery: reset the offline episode and forward the real snapshot.
|
||||
failCount = 0;
|
||||
offlineEmitted = false;
|
||||
|
||||
MachineSnapshot snapshot = result.Value;
|
||||
try
|
||||
{
|
||||
|
|
@ -95,6 +112,7 @@ namespace Junction.Core.Polling
|
|||
else
|
||||
{
|
||||
// Read failed: log and KEEP LOOPING (fault tolerance).
|
||||
readFailed = true;
|
||||
_logger.LogWarning(
|
||||
"Polling read failed for machine {MachineId} ({MachineName}): {Errors}",
|
||||
machine.Id,
|
||||
|
|
@ -103,6 +121,16 @@ namespace Junction.Core.Polling
|
|||
}
|
||||
}
|
||||
|
||||
if (readFailed)
|
||||
{
|
||||
failCount++;
|
||||
if (offlineThreshold > 0 && failCount >= offlineThreshold && !offlineEmitted)
|
||||
{
|
||||
offlineEmitted = true;
|
||||
EmitOffline(machine, onSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
|
||||
|
|
@ -115,6 +143,37 @@ namespace Junction.Core.Polling
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a synthetic Disconnected snapshot (empty items, captured now) exactly once per
|
||||
/// offline episode so the dashboard reflects a machine going down.
|
||||
/// </summary>
|
||||
private void EmitOffline(Machine machine, Action<MachineSnapshot> onSnapshot)
|
||||
{
|
||||
var offline = new MachineSnapshot(
|
||||
machine.Id,
|
||||
DateTimeOffset.UtcNow,
|
||||
ConnectionState.Disconnected,
|
||||
Array.Empty<DataItem>());
|
||||
|
||||
_logger.LogWarning(
|
||||
"Machine {MachineId} ({MachineName}) reached offline threshold; emitting Disconnected snapshot.",
|
||||
machine.Id,
|
||||
machine.Name);
|
||||
|
||||
try
|
||||
{
|
||||
onSnapshot(offline);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Offline snapshot callback threw for machine {MachineId} ({MachineName}); continuing.",
|
||||
machine.Id,
|
||||
machine.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeErrors(System.Collections.Generic.IReadOnlyList<OperationError> errors)
|
||||
{
|
||||
if (errors is null || errors.Count == 0)
|
||||
|
|
|
|||
|
|
@ -205,6 +205,186 @@ namespace Junction.Tests.Unit
|
|||
await monitor.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOrUpdateMachineAsync_StartsNewMachine_RaisesEvent_CachesSnapshot()
|
||||
{
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(m.Id)))));
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning(); // start with no machines
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||
|
||||
var added = MachineWith("test");
|
||||
var eventRaised = new ManualResetEventSlim(false);
|
||||
monitor.SnapshotUpdated += (_, snap) =>
|
||||
{
|
||||
if (snap.MachineId == added.Id) eventRaised.Set();
|
||||
};
|
||||
|
||||
var result = await monitor.AddOrUpdateMachineAsync(added, CancellationToken.None);
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
Assert.True(eventRaised.Wait(TimeSpan.FromSeconds(2)), "SnapshotUpdated not raised for added machine");
|
||||
|
||||
await monitor.StopAsync();
|
||||
Assert.True(monitor.LatestSnapshots.ContainsKey(added.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOrUpdateMachineAsync_CalledTwice_RestartsSingleLoop_NoDuplicateStream()
|
||||
{
|
||||
// Count concurrently-active drivers: a duplicate loop would push this above 1.
|
||||
int active = 0;
|
||||
int maxActive = 0;
|
||||
var gate = new object();
|
||||
|
||||
IProtocolDriver MakeDriver(Guid id) => new CountingDriver(
|
||||
() =>
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
active++;
|
||||
if (active > maxActive) maxActive = active;
|
||||
}
|
||||
return Result<MachineSnapshot>.Ok(Snapshot(id));
|
||||
},
|
||||
() => { lock (gate) { active--; } });
|
||||
|
||||
var machine = MachineWith("test");
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(MakeDriver(m.Id)));
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning();
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||
|
||||
var r1 = await monitor.AddOrUpdateMachineAsync(machine, CancellationToken.None);
|
||||
Assert.True(r1.IsSuccess);
|
||||
await Task.Delay(80);
|
||||
|
||||
// Restart same id.
|
||||
var r2 = await monitor.AddOrUpdateMachineAsync(machine, CancellationToken.None);
|
||||
Assert.True(r2.IsSuccess);
|
||||
await Task.Delay(120);
|
||||
|
||||
await monitor.StopAsync();
|
||||
|
||||
// At any instant at most one loop for this machine was polling.
|
||||
Assert.True(maxActive <= 1, $"expected a single active loop, saw {maxActive} concurrent");
|
||||
Assert.True(monitor.LatestSnapshots.ContainsKey(machine.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveMachineAsync_StopsLoop_DropsFromCache_NoFurtherUpdates()
|
||||
{
|
||||
var machine = MachineWith("test");
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(m.Id)))));
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning(machine);
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||
|
||||
// Wait until it has produced.
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (!monitor.LatestSnapshots.ContainsKey(machine.Id) && sw.Elapsed < TimeSpan.FromSeconds(2))
|
||||
{
|
||||
await Task.Delay(20);
|
||||
}
|
||||
Assert.True(monitor.LatestSnapshots.ContainsKey(machine.Id), "machine did not start");
|
||||
|
||||
int updatesAfterRemove = 0;
|
||||
var remove = await monitor.RemoveMachineAsync(machine.Id, CancellationToken.None);
|
||||
Assert.True(remove.IsSuccess);
|
||||
Assert.False(monitor.LatestSnapshots.ContainsKey(machine.Id), "removed machine must drop from cache");
|
||||
|
||||
// Subscribe only now; if the loop truly stopped no further updates arrive.
|
||||
monitor.SnapshotUpdated += (_, snap) =>
|
||||
{
|
||||
if (snap.MachineId == machine.Id) Interlocked.Increment(ref updatesAfterRemove);
|
||||
};
|
||||
|
||||
await Task.Delay(150);
|
||||
Assert.Equal(0, updatesAfterRemove);
|
||||
|
||||
await monitor.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveMachineAsync_NotPresent_IsOkIdempotent()
|
||||
{
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(m.Id)))));
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning();
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||
|
||||
var result = await monitor.RemoveMachineAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOrUpdateMachineAsync_UnknownProtocol_ReturnsFail()
|
||||
{
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(m.Id)))));
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning();
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||
|
||||
var bad = MachineWith("nope");
|
||||
var result = await monitor.AddOrUpdateMachineAsync(bad, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.NotEmpty(result.Errors);
|
||||
Assert.False(monitor.LatestSnapshots.ContainsKey(bad.Id));
|
||||
|
||||
await monitor.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AvailableProtocols_EmptyBeforeStart_ContainsPluginProtocolAfterStart()
|
||||
{
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(m.Id)))));
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning();
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
// Before start: no factory map yet.
|
||||
Assert.Empty(monitor.AvailableProtocols);
|
||||
|
||||
await monitor.StartAsync(PluginsDir, CancellationToken.None);
|
||||
|
||||
Assert.Contains("test", monitor.AvailableProtocols);
|
||||
|
||||
await monitor.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOrUpdateMachineAsync_BeforeStart_ReturnsFail()
|
||||
{
|
||||
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
|
||||
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(m.Id)))));
|
||||
var loader = LoaderReturning(factory);
|
||||
var repo = RepoReturning();
|
||||
var monitor = NewMonitor(repo.Object, loader.Object);
|
||||
|
||||
var machine = MachineWith("test");
|
||||
var result = await monitor.AddOrUpdateMachineAsync(machine, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.NotEmpty(result.Errors);
|
||||
Assert.Contains(result.Errors, e => e.Message.Contains("not started"));
|
||||
}
|
||||
|
||||
/// <summary>Hand-rolled factory; behavior supplied by a delegate.</summary>
|
||||
private sealed class FakeFactory : IProtocolDriverFactory
|
||||
{
|
||||
|
|
@ -240,5 +420,40 @@ namespace Junction.Tests.Unit
|
|||
return Task.FromResult(_read());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver that holds an "active" window across an awaited delay so a test can detect two
|
||||
/// loops polling the same machine concurrently. <paramref name="enter"/> runs at read
|
||||
/// start (increment + produce), <paramref name="exit"/> in a finally (decrement) so the
|
||||
/// count stays balanced even on cancellation.
|
||||
/// </summary>
|
||||
private sealed class CountingDriver : IProtocolDriver
|
||||
{
|
||||
private readonly Func<Result<MachineSnapshot>> _enter;
|
||||
private readonly Action _exit;
|
||||
|
||||
public CountingDriver(Func<Result<MachineSnapshot>> enter, Action exit)
|
||||
{
|
||||
_enter = enter;
|
||||
_exit = exit;
|
||||
}
|
||||
|
||||
public string ProtocolId => "test";
|
||||
|
||||
public async Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Result<MachineSnapshot> r = _enter();
|
||||
try
|
||||
{
|
||||
await Task.Delay(30, cancellationToken).ConfigureAwait(false);
|
||||
return r;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,127 @@ namespace Junction.Tests.Unit
|
|||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ConsecutiveFails_EmitsSingleDisconnectedSnapshot_AtThreshold()
|
||||
{
|
||||
var machine = MachineWithInterval(TimeSpan.FromMilliseconds(10));
|
||||
// Always fail: after `threshold` fails a synthetic Disconnected must be emitted once.
|
||||
var driver = new FakeDriver(m => Result<MachineSnapshot>.Fail(OperationError.Of("fake", "down")));
|
||||
|
||||
var emitted = new List<MachineSnapshot>();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var loop = NewEngine().RunAsync(
|
||||
machine, driver,
|
||||
s => { lock (emitted) { emitted.Add(s); } },
|
||||
cts.Token,
|
||||
offlineThreshold: 3);
|
||||
|
||||
// Plenty of time for many failed cycles.
|
||||
await Task.Delay(300);
|
||||
cts.Cancel();
|
||||
await loop;
|
||||
|
||||
lock (emitted)
|
||||
{
|
||||
// Only the synthetic Disconnected, and exactly once (no spam) despite many fails.
|
||||
Assert.Single(emitted);
|
||||
Assert.Equal(ConnectionState.Disconnected, emitted[0].ConnectionState);
|
||||
Assert.Equal(machine.Id, emitted[0].MachineId);
|
||||
Assert.Empty(emitted[0].Items);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_BelowThreshold_NoSyntheticDisconnected()
|
||||
{
|
||||
var machine = MachineWithInterval(TimeSpan.FromMilliseconds(10));
|
||||
int calls = 0;
|
||||
var emitted = new List<MachineSnapshot>();
|
||||
|
||||
// Fail twice then succeed forever: with threshold 3 the episode never trips.
|
||||
var driver = new FakeDriver(m =>
|
||||
{
|
||||
int c = Interlocked.Increment(ref calls);
|
||||
if (c <= 2)
|
||||
{
|
||||
return Result<MachineSnapshot>.Fail(OperationError.Of("fake", "blip"));
|
||||
}
|
||||
|
||||
return Result<MachineSnapshot>.Ok(Snapshot(machine.Id));
|
||||
});
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var loop = NewEngine().RunAsync(
|
||||
machine, driver,
|
||||
s => { lock (emitted) { emitted.Add(s); } },
|
||||
cts.Token,
|
||||
offlineThreshold: 3);
|
||||
|
||||
await Task.Delay(200);
|
||||
cts.Cancel();
|
||||
await loop;
|
||||
|
||||
lock (emitted)
|
||||
{
|
||||
Assert.NotEmpty(emitted);
|
||||
Assert.DoesNotContain(emitted, s => s.ConnectionState == ConnectionState.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_RecoveryAfterOffline_ResetsAndEmitsConnected_ThenReDisconnectsOncePerEpisode()
|
||||
{
|
||||
var machine = MachineWithInterval(TimeSpan.FromMilliseconds(10));
|
||||
int calls = 0;
|
||||
var emitted = new List<MachineSnapshot>();
|
||||
|
||||
// Episode 1: 3 fails -> Disconnected. Then 1 Ok -> Connected (resets).
|
||||
// Episode 2: 3 fails -> Disconnected again (proves reset + one-per-episode).
|
||||
var driver = new FakeDriver(m =>
|
||||
{
|
||||
int c = Interlocked.Increment(ref calls);
|
||||
// cycles 1-3 fail, 4 ok, 5-7 fail, then ok forever.
|
||||
bool fail = c <= 3 || (c >= 5 && c <= 7);
|
||||
if (fail)
|
||||
{
|
||||
return Result<MachineSnapshot>.Fail(OperationError.Of("fake", "down"));
|
||||
}
|
||||
|
||||
return Result<MachineSnapshot>.Ok(Snapshot(machine.Id));
|
||||
});
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var loop = NewEngine().RunAsync(
|
||||
machine, driver,
|
||||
s => { lock (emitted) { emitted.Add(s); } },
|
||||
cts.Token,
|
||||
offlineThreshold: 3);
|
||||
|
||||
// Wait until we observe both disconnected episodes (2) or time out.
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (sw.Elapsed < TimeSpan.FromSeconds(3))
|
||||
{
|
||||
lock (emitted)
|
||||
{
|
||||
int disc = emitted.FindAll(s => s.ConnectionState == ConnectionState.Disconnected).Count;
|
||||
int conn = emitted.FindAll(s => s.ConnectionState == ConnectionState.Connected).Count;
|
||||
if (disc >= 2 && conn >= 1) break;
|
||||
}
|
||||
await Task.Delay(15);
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
await loop;
|
||||
|
||||
lock (emitted)
|
||||
{
|
||||
int disconnected = emitted.FindAll(s => s.ConnectionState == ConnectionState.Disconnected).Count;
|
||||
int connected = emitted.FindAll(s => s.ConnectionState == ConnectionState.Connected).Count;
|
||||
Assert.Equal(2, disconnected); // one per episode, no spam
|
||||
Assert.True(connected >= 1, "recovery must emit at least one Connected snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Hand-rolled fake driver; behavior supplied by a delegate.</summary>
|
||||
private sealed class FakeDriver : IProtocolDriver
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue