From 6f75f7feb11d6645fdce4de11eeca059dc7829ac Mon Sep 17 00:00:00 2001 From: dtrentin Date: Tue, 21 Jul 2026 23:55:10 +0200 Subject: [PATCH] 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) --- .gitignore | 3 + .paul/PROJECT.md | 119 +++++++++ .paul/ROADMAP.md | 84 +++++++ .paul/STATE.md | 64 +++++ .paul/paul.json | 25 ++ src/Junction.App/Bootstrapper.cs | 10 + .../ViewModels/DashboardViewModel.cs | 24 +- .../ViewModels/DataItemRowViewModel.cs | 27 +++ .../ViewModels/MachineConfigViewModel.cs | 208 ++++++++++++++++ .../ViewModels/MachineDetailViewModel.cs | 228 ++++++++++++++++++ .../ViewModels/MachineRowViewModel.cs | 10 +- .../ViewModels/MainWindowViewModel.cs | 60 ++++- src/Junction.App/Views/DashboardView.axaml | 22 +- .../Views/MachineConfigView.axaml | 61 +++++ .../Views/MachineConfigView.axaml.cs | 12 + .../Views/MachineDetailView.axaml | 86 +++++++ .../Views/MachineDetailView.axaml.cs | 12 + .../Junction.Protocols.MTConnect.dll | Bin 18944 -> 0 bytes .../plugins/mtconnect/plugin.manifest.json | 7 - .../Monitoring/IMachineMonitor.cs | 32 +++ .../Monitoring/MachineMonitor.cs | 183 ++++++++++++++ src/Junction.Core/Polling/IPollingEngine.cs | 10 +- src/Junction.Core/Polling/PollingEngine.cs | 63 ++++- .../Unit/MachineMonitorTests.cs | 215 +++++++++++++++++ .../Junction.Tests/Unit/PollingEngineTests.cs | 121 ++++++++++ 25 files changed, 1664 insertions(+), 22 deletions(-) create mode 100644 .paul/PROJECT.md create mode 100644 .paul/ROADMAP.md create mode 100644 .paul/STATE.md create mode 100644 .paul/paul.json create mode 100644 src/Junction.App/ViewModels/DataItemRowViewModel.cs create mode 100644 src/Junction.App/ViewModels/MachineConfigViewModel.cs create mode 100644 src/Junction.App/ViewModels/MachineDetailViewModel.cs create mode 100644 src/Junction.App/Views/MachineConfigView.axaml create mode 100644 src/Junction.App/Views/MachineConfigView.axaml.cs create mode 100644 src/Junction.App/Views/MachineDetailView.axaml create mode 100644 src/Junction.App/Views/MachineDetailView.axaml.cs delete mode 100644 src/Junction.App/plugins/mtconnect/Junction.Protocols.MTConnect.dll delete mode 100644 src/Junction.App/plugins/mtconnect/plugin.manifest.json diff --git a/.gitignore b/.gitignore index b35f024..5dd1337 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.paul/PROJECT.md b/.paul/PROJECT.md new file mode 100644 index 0000000..52cc3f7 --- /dev/null +++ b/.paul/PROJECT.md @@ -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, 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* diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md new file mode 100644 index 0000000..cd6b9f4 --- /dev/null +++ b/.paul/ROADMAP.md @@ -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 + +
+v0.1 Walking Skeleton (Phase 1) — Shipped 2026-07-21 + +### 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, 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)` + +
+ +--- +*Roadmap created: 2026-07-21* +*Last updated: 2026-07-21* diff --git a/.paul/STATE.md b/.paul/STATE.md new file mode 100644 index 0000000..9724927 --- /dev/null +++ b/.paul/STATE.md @@ -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* diff --git a/.paul/paul.json b/.paul/paul.json new file mode 100644 index 0000000..5496682 --- /dev/null +++ b/.paul/paul.json @@ -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 + } +} diff --git a/src/Junction.App/Bootstrapper.cs b/src/Junction.App/Bootstrapper.cs index b976eb6..b38f9e9 100644 --- a/src/Junction.App/Bootstrapper.cs +++ b/src/Junction.App/Bootstrapper.cs @@ -42,6 +42,16 @@ namespace Junction.App services.AddSingleton(); services.AddSingleton(); + // Detail is per-navigation (transient); a Func factory lets the shell mint one per click. + services.AddTransient(); + services.AddSingleton>(sp => + () => sp.GetRequiredService()); + + // Config screen is per-navigation (transient); a Func factory mints one per add/edit. + services.AddTransient(); + services.AddSingleton>(sp => + () => sp.GetRequiredService()); + return services.BuildServiceProvider(); } } diff --git a/src/Junction.App/ViewModels/DashboardViewModel.cs b/src/Junction.App/ViewModels/DashboardViewModel.cs index 11f022a..dfebdb5 100644 --- a/src/Junction.App/ViewModels/DashboardViewModel.cs +++ b/src/Junction.App/ViewModels/DashboardViewModel.cs @@ -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"; + /// + /// Shell reference, set by after construction (breaks the + /// otherwise-circular DI graph). Used to open a machine's detail screen on row click. + /// + public MainWindowViewModel? Navigator { get; set; } + public ObservableCollection Machines { get; } = new ObservableCollection(); public DashboardViewModel(IMachineRepository repository, IMachineMonitor monitor, ILogger 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); } + /// Header action: opens the add-machine config screen via the shell. + [RelayCommand] + private void AddMachine() => Navigator?.ShowConfig(null); + + /// Row-click handler: routes to the machine-detail screen via the shell. + 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) diff --git a/src/Junction.App/ViewModels/DataItemRowViewModel.cs b/src/Junction.App/ViewModels/DataItemRowViewModel.cs new file mode 100644 index 0000000..34b6bc2 --- /dev/null +++ b/src/Junction.App/ViewModels/DataItemRowViewModel.cs @@ -0,0 +1,27 @@ +using Junction.Domain.Models; + +namespace Junction.App.ViewModels +{ + /// + /// One row of the machine-detail data-item table. Immutable projection of a + /// ; rebuilt (not mutated) whenever a fresh snapshot arrives, + /// so it needs no change-notification. + /// + 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"); + } + } +} diff --git a/src/Junction.App/ViewModels/MachineConfigViewModel.cs b/src/Junction.App/ViewModels/MachineConfigViewModel.cs new file mode 100644 index 0000000..d679423 --- /dev/null +++ b/src/Junction.App/ViewModels/MachineConfigViewModel.cs @@ -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 +{ + /// + /// Add/edit machine form. Two modes selected by : + /// null id = ADD (blank form, fresh 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 + /// so the poll loop starts/restarts without an app restart. routes back + /// to the dashboard (and triggers its reload) once the mutation completes. + /// Created per-navigation (transient). Set , then call + /// . + /// + public sealed partial class MachineConfigViewModel : ViewModelBase + { + private readonly IMachineRepository _repository; + private readonly IMachineMonitor _monitor; + private readonly ILogger _logger; + + private Guid? _editingId; + + /// Shell reference used to return to the dashboard after save/cancel. + 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; + + /// Protocol ids offered in the dropdown, sourced from the monitor at Initialize. + public ObservableCollection AvailableProtocols { get; } = new ObservableCollection(); + + public MachineConfigViewModel( + IMachineRepository repository, + IMachineMonitor monitor, + ILogger logger) + { + _repository = repository; + _monitor = monitor; + _logger = logger; + } + + public string Title => IsEdit ? "Edit Machine" : "Add Machine"; + + /// Validation message for display; empty when the form is valid. + public string ValidationError => Validate() ?? ""; + + public bool HasValidationError => Validate() != null; + + partial void OnIsEditChanged(bool value) => OnPropertyChanged(nameof(Title)); + + /// + /// 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). + /// + 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 { ["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(); + } +} diff --git a/src/Junction.App/ViewModels/MachineDetailViewModel.cs b/src/Junction.App/ViewModels/MachineDetailViewModel.cs new file mode 100644 index 0000000..9ca3425 --- /dev/null +++ b/src/Junction.App/ViewModels/MachineDetailViewModel.cs @@ -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 +{ + /// + /// 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 + /// fires for this machine. Snapshot events + /// arrive on poll-loop threads and are marshalled onto the UI thread. + /// Created per-navigation (transient); call then + /// . is set by the shell to route Back. + /// + public sealed partial class MachineDetailViewModel : ViewModelBase, IDisposable + { + private readonly IMachineRepository _repository; + private readonly IMachineMonitor _monitor; + private readonly ILogger _logger; + private Guid _machineId; + private bool _subscribed; + private bool _disposed; + + /// Shell reference used by to return to the dashboard. + 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; + + /// Two-state delete guard: first click arms, second click confirms. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DeleteButtonText))] + private bool _confirmingDelete; + + public string DeleteButtonText => ConfirmingDelete ? "Confirm delete?" : "Delete"; + + /// The full set of current data items for this machine. + public ObservableCollection Items { get; } = + new ObservableCollection(); + + public MachineDetailViewModel( + IMachineRepository repository, + IMachineMonitor monitor, + ILogger logger) + { + _repository = repository; + _monitor = monitor; + _logger = logger; + } + + /// Sets the target machine. Call before . + public void Initialize(Guid machineId, string machineName) + { + _machineId = machineId; + MachineName = string.IsNullOrWhiteSpace(machineName) ? "—" : machineName; + MachineIdText = machineId.ToString(); + } + + /// Loads the machine header + latest snapshot and subscribes to live updates. + 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); + } + + /// Projects a snapshot onto the header state + item table. Call on the UI thread. + 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(); + } + + /// Opens the edit config screen for this machine. + [RelayCommand] + private void Edit() + { + Dispose(); + Navigator?.ShowConfig(_machineId); + } + + /// + /// 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. + /// + [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; + } + } + } +} diff --git a/src/Junction.App/ViewModels/MachineRowViewModel.cs b/src/Junction.App/ViewModels/MachineRowViewModel.cs index 64c4efa..f0a651a 100644 --- a/src/Junction.App/ViewModels/MachineRowViewModel.cs +++ b/src/Junction.App/ViewModels/MachineRowViewModel.cs @@ -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 /// public sealed partial class MachineRowViewModel : ViewModelBase { + private readonly Action? _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? onOpenDetail = null) { + _onOpenDetail = onOpenDetail; MachineId = machine.Id; _name = machine.Name; _protocolId = machine.ProtocolId; @@ -28,6 +32,10 @@ namespace Junction.App.ViewModels _lastUpdated = "—"; } + /// Opens the detail screen for this machine via the dashboard-supplied callback. + [RelayCommand] + private void OpenDetail() => _onOpenDetail?.Invoke(this); + /// Projects a snapshot onto this row. Call on the UI thread. public void Apply(MachineSnapshot snapshot) { diff --git a/src/Junction.App/ViewModels/MainWindowViewModel.cs b/src/Junction.App/ViewModels/MainWindowViewModel.cs index c406baf..01611a1 100644 --- a/src/Junction.App/ViewModels/MainWindowViewModel.cs +++ b/src/Junction.App/ViewModels/MainWindowViewModel.cs @@ -1,22 +1,74 @@ +using System; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; namespace Junction.App.ViewModels { /// - /// Shell view-model. Hosts the currently shown page. Only the dashboard is used this slice; - /// 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). + /// is the low-level seam; / + /// / are the app-level transitions. /// public sealed partial class MainWindowViewModel : ViewModelBase { + private readonly DashboardViewModel _dashboard; + private readonly Func _detailFactory; + private readonly Func _configFactory; + [ObservableProperty] private object? _currentPage; - public MainWindowViewModel(DashboardViewModel dashboard) + public MainWindowViewModel( + DashboardViewModel dashboard, + Func detailFactory, + Func 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; } - /// Navigation seam. Swaps the hosted page. (Only Dashboard wired now.) + /// Navigation seam. Swaps the hosted page. public void Navigate(object viewModel) => CurrentPage = viewModel; + + /// Returns to the (singleton, still-live) dashboard. + public void GoToDashboard() => CurrentPage = _dashboard; + + /// + /// 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. + /// + public async Task GoToDashboardAndReloadAsync() + { + CurrentPage = _dashboard; + await _dashboard.LoadAsync().ConfigureAwait(true); + } + + /// Opens the add (null id) or edit (existing id) config screen. + public void ShowConfig(Guid? machineId) + { + var config = _configFactory(); + config.Navigator = this; + config.Initialize(machineId); + Navigate(config); + } + + /// Builds a fresh detail page for the given machine, loads it, and shows it. + 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(); + } } } diff --git a/src/Junction.App/Views/DashboardView.axaml b/src/Junction.App/Views/DashboardView.axaml index 6e7e244..337f439 100644 --- a/src/Junction.App/Views/DashboardView.axaml +++ b/src/Junction.App/Views/DashboardView.axaml @@ -9,20 +9,27 @@ x:CompileBindings="True"> - + + +