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:
dtrentin 2026-07-21 23:55:10 +02:00
parent 507753f82e
commit 6f75f7feb1
25 changed files with 1664 additions and 22 deletions

3
.gitignore vendored
View file

@ -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
View 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
View 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 (T1T30 + 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
View 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
View 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
}
}

View file

@ -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();
}
}

View file

@ -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)

View 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");
}
}
}

View 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();
}
}

View 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;
}
}
}
}

View file

@ -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)
{

View file

@ -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();
}
}
}

View file

@ -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>

View 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>

View 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);
}
}

View 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>

View 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);
}
}

View file

@ -1,7 +0,0 @@
{
"protocolId": "mtconnect",
"displayName": "MTConnect",
"assemblyFile": "Junction.Protocols.MTConnect.dll",
"entryTypeName": "Junction.Protocols.MTConnect.MtconnectDriverFactory",
"apiVersion": "1.0"
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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 &lt;= 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);
}
}

View file

@ -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)

View file

@ -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();
}
}
}
}
}

View file

@ -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
{