diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md index c8b3102..dd66d08 100644 --- a/.paul/ROADMAP.md +++ b/.paul/ROADMAP.md @@ -6,7 +6,7 @@ |---------|------|--------|--------|-----------| | v0.1 | Walking Skeleton (MTConnect) | 1 | βœ… Shipped | 2026-07-21 | | v0.2 | Machine Management UX | 2, 2.1 | βœ… Shipped (2.1 commit pending) | 2026-07-21 | -| v0.3 | Data-Item Selection + UX | 3, 3.1 | πŸ“‹ Planned | - | +| v0.3 | Data-Item Selection + UX | 3, 3.1 | 🚧 Phase 3 done, 3.1 (theming) next | - | | v0.4 | OPC UA Protocol | 4 | πŸ“‹ Planned | - | | v0.5 | Fanuc FOCAS Protocol | 5 | πŸ“‹ Planned | - | | v0.6 | History & Trends | 6 | πŸ“‹ Planned | - | diff --git a/.paul/STATE.md b/.paul/STATE.md index 293f478..79d2a08 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -5,27 +5,30 @@ 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 + v0.2.1 shipped β†’ starting v0.3 Data-Item Selection +**Current focus:** v0.3 Phase 3 (item selection) shipped β†’ PAUSED. Next: Phase 3.1 theming/UX. ## Current Position -Milestone: v0.2 Machine Management UX (+ 2.1 refinements) β€” SHIPPED -Phase: 2 βœ“, 2.1 βœ“ β†’ next Phase 3 (Data-Item Selection) -Plan: 02-A/B/C βœ“, 02.1 R1/R2R3/tablefix βœ“ -Status: v0.2.1 committing; v0.3 backend next -Last activity: 2026-07-22 β€” v0.2.1 done: shared HttpClient, disconnection UX, delete-confirm overlay, dashboard DataGrid (aligned + Details column). Build 0/0, 127 tests + 2 docker-skip. +Milestone: v0.3 Data-Item Selection + UX +Phase: 3 (item selection) βœ“ SHIPPED β€” Phase 3.1 (theming/UX) NOT started +Plan: v0.3-backend βœ“, v0.3-appui βœ“ +Status: PAUSED (user requested). Build 0/0, 138 tests + 2 docker-skip. +Last activity: 2026-07-22 β€” v0.3 item selection: Machine.MonitoredItemIds (opt-in), IProtocolDriver.ProbeAsync, MTConnect probe+filter, Persistence col+migration, monitor.ProbeAsync, config probeβ†’checklist UI (47 items live), detail empty-state. Progress: -- v0.2 (+2.1): [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ] 100% -- Next: Phase 3 Data-Item Selection +- v0.3: Phase 3 done, Phase 3.1 (theming) pending ## Loop Position ``` PLAN ──▢ APPLY ──▢ UNIFY - βœ“ βœ“ βœ“ [v0.2.1 complete β†’ PLAN Phase 3] + βœ“ βœ“ βœ“ [Phase 3 complete β†’ PAUSED; next PLAN Phase 3.1 theming] ``` +## NEXT (on resume) + +Phase 3.1 theming/UX (task #34): Fluent light/dark + accent, per-protocol icons (mtconnect/opc/fanuc), curated layout across dashboard/detail/config. App-only. Then v0.4 OPC UA plugin. + ## Standing Authorization Auto-commit + push GREEN chunks (build 0-err + tests pass) to origin/main WITHOUT asking (user-granted 2026-07-22). Never commit red; no force-push; artifact leak-check each time. diff --git a/.paul/paul.json b/.paul/paul.json index 2e8ad49..6c7989c 100644 --- a/.paul/paul.json +++ b/.paul/paul.json @@ -1,23 +1,23 @@ { "name": "Junction", - "version": "0.2.0", + "version": "0.3.0", "milestone": { - "name": "Machine Management UX", - "version": "0.2.0", - "status": "shipped" + "name": "Data-Item Selection + UX", + "version": "0.3.0", + "status": "in_progress" }, "phase": { - "number": 2, - "name": "Machine Management UX", + "number": 3, + "name": "Data-Item Selection", "status": "complete" }, "loop": { - "plan": "02-C", - "position": "UNIFY" + "plan": "v0.3-appui", + "position": "PAUSED" }, "timestamps": { "created_at": "2026-07-21T23:30:00Z", - "updated_at": "2026-07-21T23:45:00Z" + "updated_at": "2026-07-22T00:40:00Z" }, "satellite": { "groom": true diff --git a/src/Junction.App/ViewModels/MachineConfigViewModel.cs b/src/Junction.App/ViewModels/MachineConfigViewModel.cs index d679423..5f0c5be 100644 --- a/src/Junction.App/ViewModels/MachineConfigViewModel.cs +++ b/src/Junction.App/ViewModels/MachineConfigViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; @@ -8,6 +9,7 @@ using CommunityToolkit.Mvvm.Input; using Junction.Core.Monitoring; using Junction.Domain.Models; using Junction.Domain.Persistence; +using Junction.Domain.Protocols; using Microsoft.Extensions.Logging; namespace Junction.App.ViewModels @@ -30,6 +32,14 @@ namespace Junction.App.ViewModels private Guid? _editingId; + /// + /// MonitoredItemIds the machine had when loaded for EDIT. Used to pre-select the probed + /// catalog and to PRESERVE the selection on save when the user never reloaded the catalog + /// (an empty/never-probed must not wipe an existing selection). + /// Empty for ADD. + /// + private List _existingMonitoredItemIds = new List(); + /// Shell reference used to return to the dashboard after save/cancel. public MainWindowViewModel? Navigator { get; set; } @@ -41,12 +51,14 @@ namespace Junction.App.ViewModels [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + [NotifyCanExecuteChangedFor(nameof(LoadItemsCommand))] [NotifyPropertyChangedFor(nameof(ValidationError))] [NotifyPropertyChangedFor(nameof(HasValidationError))] private string _selectedProtocol = ""; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + [NotifyCanExecuteChangedFor(nameof(LoadItemsCommand))] [NotifyPropertyChangedFor(nameof(ValidationError))] [NotifyPropertyChangedFor(nameof(HasValidationError))] private string _agentUrl = ""; @@ -59,9 +71,29 @@ namespace Junction.App.ViewModels [ObservableProperty] private bool _isEdit; + /// True while a probe (LoadItems) is in flight; drives the busy indicator and disables the button. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LoadItemsCommand))] + private bool _isProbing; + + /// User-visible status for the item section (probe failures, hints). Empty when nothing to say. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasItemsStatus))] + private string _itemsStatus = ""; + + public bool HasItemsStatus => !string.IsNullOrEmpty(ItemsStatus); + /// Protocol ids offered in the dropdown, sourced from the monitor at Initialize. public ObservableCollection AvailableProtocols { get; } = new ObservableCollection(); + /// + /// Catalog of selectable data items for the current machine, populated by . + /// Empty until the user probes (or, in EDIT after a failed probe, seeded from the existing + /// selection as a fallback so the user can still deselect items while the agent is offline). + /// + public ObservableCollection AvailableItems { get; } = + new ObservableCollection(); + public MachineConfigViewModel( IMachineRepository repository, IMachineMonitor monitor, @@ -93,12 +125,16 @@ namespace Junction.App.ViewModels _editingId = machineId; IsEdit = machineId.HasValue; + _existingMonitoredItemIds = new List(); + AvailableItems.Clear(); + if (machineId is null) { Name = ""; SelectedProtocol = AvailableProtocols.Count > 0 ? AvailableProtocols[0] : ""; AgentUrl = ""; PollIntervalSeconds = 2; + ItemsStatus = "Load available items to choose what to monitor (requires a reachable agent)."; } else { @@ -137,6 +173,123 @@ namespace Junction.App.ViewModels SelectedProtocol = m.ProtocolId; AgentUrl = m.ConnectionConfig.TryGetValue("AgentUrl", out var url) ? url : ""; PollIntervalSeconds = m.PollInterval.TotalSeconds > 0 ? (int)m.PollInterval.TotalSeconds : 1; + + _existingMonitoredItemIds = m.MonitoredItemIds != null + ? m.MonitoredItemIds.ToList() + : new List(); + + ItemsStatus = _existingMonitoredItemIds.Count > 0 + ? "Currently monitoring " + _existingMonitoredItemIds.Count + " item(s). Load available items to change the selection." + : "No items selected yet. Load available items to choose what to monitor."; + } + + /// + /// Probes the machine for its full data-item catalog and populates . + /// Runs off the button click (async): sets for the duration, marshals + /// nothing manually β€” the awaited continuation resumes on the UI thread. On success each item is + /// pre-selected when its id is already in the machine's existing selection. On failure a + /// user-visible message is set and (in EDIT) the existing selection is shown as a fallback so + /// the user can still deselect items while the agent is offline. + /// + [RelayCommand(CanExecute = nameof(CanLoadItems))] + private async Task LoadItems() + { + IsProbing = true; + ItemsStatus = "Probing agent…"; + try + { + var machine = new Machine( + _editingId ?? Guid.NewGuid(), + string.IsNullOrWhiteSpace(Name) ? "(probe)" : Name.Trim(), + SelectedProtocol, + new Dictionary { ["AgentUrl"] = AgentUrl.Trim() }, + TimeSpan.FromSeconds(PollIntervalSeconds > 0 ? PollIntervalSeconds : 1)); + + var result = await _monitor.ProbeAsync(machine, CancellationToken.None).ConfigureAwait(true); + + if (!result.IsSuccess) + { + var detail = result.WasCancelled + ? "cancelled" + : (result.Errors.Count > 0 ? result.Errors[0].Message : "unknown error"); + _logger.LogWarning("Probe failed for {MachineId} ({Protocol} @ {Url}): {Detail}", + machine.Id, SelectedProtocol, AgentUrl, detail); + + ShowFallbackSelection(); + ItemsStatus = "Probe failed: " + detail + + ". Check the Agent URL and that the machine is reachable." + + (AvailableItems.Count > 0 ? " Showing the existing selection so you can still deselect items." : ""); + return; + } + + var existing = new HashSet(_existingMonitoredItemIds, StringComparer.Ordinal); + AvailableItems.Clear(); + var catalog = result.Value ?? (IReadOnlyList)Array.Empty(); + foreach (var descriptor in catalog) + { + AvailableItems.Add(new SelectableDataItemViewModel(descriptor, existing.Contains(descriptor.Id))); + } + + _logger.LogInformation("Probe returned {Count} item(s) for {MachineId}.", AvailableItems.Count, machine.Id); + + ItemsStatus = AvailableItems.Count == 0 + ? "Probe succeeded but the machine exposes no data items." + : AvailableItems.Count + " item(s) available. Tick the ones to monitor."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Probe threw for {Protocol} @ {Url}.", SelectedProtocol, AgentUrl); + ShowFallbackSelection(); + ItemsStatus = "Probe failed: " + ex.Message + + ". Check the Agent URL and that the machine is reachable."; + } + finally + { + IsProbing = false; + } + } + + private bool CanLoadItems() => + !IsProbing + && !string.IsNullOrWhiteSpace(SelectedProtocol) + && !string.IsNullOrWhiteSpace(AgentUrl) + && Uri.TryCreate(AgentUrl, UriKind.Absolute, out _); + + /// + /// Seeds from the existing selection (EDIT) when a probe could not + /// return a catalog, so the user can still deselect items offline. No-op if already populated or + /// there is no existing selection. + /// + private void ShowFallbackSelection() + { + if (AvailableItems.Count > 0 || _existingMonitoredItemIds.Count == 0) + { + return; + } + + foreach (var id in _existingMonitoredItemIds) + { + AvailableItems.Add(new SelectableDataItemViewModel( + new DataItemDescriptor(id, id, "", "", ""), true)); + } + } + + [RelayCommand] + private void SelectAll() + { + foreach (var item in AvailableItems) + { + item.IsSelected = true; + } + } + + [RelayCommand] + private void SelectNone() + { + foreach (var item in AvailableItems) + { + item.IsSelected = false; + } } private string? Validate() @@ -170,12 +323,20 @@ namespace Junction.App.ViewModels private async Task Save() { var id = _editingId ?? Guid.NewGuid(); + + // Selection source: if the user probed (AvailableItems populated), take the ticked ids. + // Otherwise PRESERVE the existing selection (EDIT with no reload) β€” empty for ADD (opt-in). + IReadOnlyCollection monitoredItemIds = AvailableItems.Count > 0 + ? AvailableItems.Where(i => i.IsSelected).Select(i => i.Descriptor.Id).ToList() + : _existingMonitoredItemIds.ToList(); + var machine = new Machine( id, Name.Trim(), SelectedProtocol, new Dictionary { ["AgentUrl"] = AgentUrl.Trim() }, - TimeSpan.FromSeconds(PollIntervalSeconds)); + TimeSpan.FromSeconds(PollIntervalSeconds), + monitoredItemIds); var upsert = await _repository.UpsertAsync(machine, CancellationToken.None).ConfigureAwait(true); if (!upsert.IsSuccess) diff --git a/src/Junction.App/ViewModels/MachineDetailViewModel.cs b/src/Junction.App/ViewModels/MachineDetailViewModel.cs index 3095e3c..3ef1c2f 100644 --- a/src/Junction.App/ViewModels/MachineDetailViewModel.cs +++ b/src/Junction.App/ViewModels/MachineDetailViewModel.cs @@ -53,7 +53,16 @@ namespace Junction.App.ViewModels /// Timestamp of the last CONNECTED snapshot; "β€”" until first connect. [ObservableProperty] private string _lastSeen = "β€”"; - [ObservableProperty] private int _itemCount; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasItems))] + [NotifyPropertyChangedFor(nameof(HasNoItems))] + private int _itemCount; + + /// True when there is at least one monitored data item to display. + public bool HasItems => ItemCount > 0; + + /// True when nothing is monitored β€” drives the empty-state hint instead of a blank table. + public bool HasNoItems => ItemCount == 0; /// True only when currently connected. public bool IsOnline => ConnectionState == ConnectionState.Connected; diff --git a/src/Junction.App/ViewModels/SelectableDataItemViewModel.cs b/src/Junction.App/ViewModels/SelectableDataItemViewModel.cs new file mode 100644 index 0000000..ae7051f --- /dev/null +++ b/src/Junction.App/ViewModels/SelectableDataItemViewModel.cs @@ -0,0 +1,35 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Junction.Domain.Protocols; + +namespace Junction.App.ViewModels +{ + /// + /// One selectable row in the config screen's monitored-item checklist. Wraps a + /// (catalog entry from a probe) and carries the user's + /// choice. The descriptor is immutable; only the selection mutates. + /// + public sealed partial class SelectableDataItemViewModel : ObservableObject + { + /// The underlying catalog entry. Its Id is what gets persisted when selected. + public DataItemDescriptor Descriptor { get; } + + public string Id => Descriptor.Id; + + /// Display name; falls back to the id when the descriptor carries no name. + public string Name => string.IsNullOrWhiteSpace(Descriptor.Name) ? Descriptor.Id : Descriptor.Name; + + public string Type => string.IsNullOrWhiteSpace(Descriptor.Type) ? "β€”" : Descriptor.Type; + + public string Category => string.IsNullOrWhiteSpace(Descriptor.Category) ? "β€”" : Descriptor.Category; + + public string Units => string.IsNullOrWhiteSpace(Descriptor.Units) ? "β€”" : Descriptor.Units; + + [ObservableProperty] private bool _isSelected; + + public SelectableDataItemViewModel(DataItemDescriptor descriptor, bool isSelected) + { + Descriptor = descriptor; + _isSelected = isSelected; + } + } +} diff --git a/src/Junction.App/Views/MachineConfigView.axaml b/src/Junction.App/Views/MachineConfigView.axaml index b9fa742..1238c30 100644 --- a/src/Junction.App/Views/MachineConfigView.axaml +++ b/src/Junction.App/Views/MachineConfigView.axaml @@ -57,5 +57,73 @@ + + + + + + + + + +